Some Laravel applications that sell products or services need more than a flat percentage off. An e-commerce store might schedule a seasonal sale, or a SaaS product might hand out a launch coupon, and a discount that starts at 10% off can be capped at a maximum, tied to a minimum order total, a per-customer limit, and an expiry date. Laravel Discount by Milwad Khosravi stores all of that on Eloquent models and evaluates it through a single facade.
Here's what the package covers:
- Two discount types:
DiscountType::PercentageandDiscountType::Fixed, with an optionalmax_discount_amountcap. - Coupon codes: Discounts with a
codeact as coupons, while codeless discounts apply automatically. - Time windows:
starts_atandexpires_atcolumns, paired with avalid()query scope. - Usage limits: Total
usage_limitand per-customerusage_limit_per_userenforcement. - Guest support: Pass a session ID to enforce per-user limits for unauthenticated users.
- Stacking rules: Mark discounts
is_stackableand the package works out which combination saves the customer the most. - Discountable models: The
HasDiscountstrait attaches discounts to any Eloquent model. - Cart integration: A
CartDiscountservice that applies codes to a Laravel Cart total or to a single cart item.
Percentage and Fixed Discounts
A discount is a standard Eloquent model, so you create one like any other record:
use Binafy\LaravelDiscount\Enums\DiscountType;use Binafy\LaravelDiscount\Models\Discount; $discount = Discount::query()->create([ 'name' => 'Summer Sale', 'type' => DiscountType::Percentage, 'value' => 20,]);
Applying it goes through the LaravelDiscount facade, which validates the discount before calculating anything and hands back a DiscountResult:
use Binafy\LaravelDiscount\Facades\LaravelDiscount; $result = LaravelDiscount::apply($discount, 200); $result->originalAmount; // 200.0$result->discountAmount; // 40.0$result->payableAmount(); // 160.0
The DiscountResult object holds the applied discounts, the original total, and the calculated discount value. payableAmount() subtracts the discount from the original total, clamping the result to a minimum of zero so that fixed discounts never yield negative totals.
A discount of either type can carry a ceiling through max_discount_amount, which is what covers the "20% off, up to $100" case that otherwise ends up hardcoded in a controller:
$discount = Discount::query()->create([ 'code' => 'SAVE20', 'type' => DiscountType::Percentage, 'value' => 20, 'max_discount_amount' => 100,]); LaravelDiscount::apply($discount, 300)->discountAmount; // 60.0LaravelDiscount::apply($discount, 1000)->discountAmount; // 100.0
Discount Codes, Expiry, and Usage Limits
Setting a code column turns a discount into a promotional coupon. applyCode() validates the code and throws DiscountNotFoundException if no match exists:
$result = LaravelDiscount::applyCode('WELCOME10', 200, $user);
For campaigns where every customer needs their own code, the package builds them with random_int() and leaves out ambiguous characters like 0/O and 1/I, so nobody misreads a code off a printed card:
LaravelDiscount::generateCode(); // "8FJ2K9QW"LaravelDiscount::generateCodes(100, 'VIP'); // Collection of 100 unique codes
Length, alphabet, prefix, and separator all live under the codes key in config/laravel-discount.php.
Time-limited offers use starts_at and expires_at. Applying before the window throws DiscountNotStartedException, applying after it throws DiscountExpiredException and fires a DiscountExpired event. Querying for what is live right now is a scope:
Discount::query()->valid()->get();
usage_limit caps total redemptions, and usage_limit_per_user caps them per customer, but neither is settled at the time of application alone. You call redeem() when the order is actually finalised:
LaravelDiscount::redeem($discount, $user, $result->discountAmount);
That runs within a transaction and increments used_count by the limit in the where clause, so the database decides whether the increment occurs at all. Update zero rows and the limit was already reached, which surfaces as DiscountUsageLimitReachedException. Two customers claiming the hundredth spot at the same moment cannot both win it.
Guests get the same treatment through a session ID instead of a user model, tracked in the session_id column of discount_usages alongside the nullable user_id:
$result = LaravelDiscount::applyCode('GUEST10', $total, sessionId: session()->getId()); LaravelDiscount::redeem($discount, amount: $result->discountAmount, sessionId: session()->getId());
Conditional and Stackable Discounts
A min_order_value column gates a discount behind a spending threshold and throws MinimumOrderValueException when the total falls short. There's also a conditions JSON column for storing your own condition data, which is where you'd hang anything the package doesn't model natively. If you want conditions expressed as composable PHP objects instead, the Discountify package takes that approach.
Discounts can also attach to models. Add the HasDiscounts trait, and you get a polymorphic relationship backed by the discountables table:
use Binafy\LaravelDiscount\Traits\HasDiscounts; class Product extends Model{ use HasDiscounts;}
$product->discounts()->attach($discount);$product->validDiscounts();$product->hasDiscount('TECH10'); $result = $product->applyDiscounts($product->price);
That last call routes through applyMany(), which is the stacking resolver. It drops any discount that fails validation, splits the rest into stackable and non-stackable, sums the stackable ones (capped at the order total), finds the single best non-stackable one, and returns whichever side saves more:
$result = LaravelDiscount::applyMany([$tenPercent, $tenFixed, $bigSolo], 100); $result->discounts; // the ones that actually applied$result->discountAmount; // the winning total
The discounts collection on the result matters here. After a stacking decision, you often need to show the customer which codes made the cut, and that collection is the answer.
Laravel Cart Integration
Install binafy/laravel-cart, the cart package from the same author that we covered previously, and a CartDiscount service becomes available for cart-level and item-level discounts:
use Binafy\LaravelDiscount\Integrations\LaravelCart\CartDiscount; $cartDiscount = app(CartDiscount::class); $result = $cartDiscount->applyToCart($cart, 'SUMMER-8FJ2K9QW'); $result = $cartDiscount->applyToItem($cartItem, $discount); $result = $cartDiscount->applyItemDiscounts($cart);
applyToCart() checks the cart total against min_order_value and pulls the cart's user for the per-user limit, so you don't pass either one yourself. applyToItem() works against price multiplied by quantity for a single line. applyItemDiscounts() walks the cart and applies whatever each item's underlying model has attached through HasDiscounts, which is how you run a product-level sale across a whole basket without touching the cart total.
Validation, Exceptions, and Events
Checkout forms need to reject invalid code before anything else happens, and the package includes a ValidDiscountCode rule for that. Its message names the actual reason rather than failing generically:
use Binafy\LaravelDiscount\Rules\ValidDiscountCode; public function rules(): array{ return [ 'code' => ['required', new ValidDiscountCode( orderAmount: $this->cartTotal(), user: $this->user(), )], ];}
It's a standard rule object, so it composes with the rest of a form request the same way any custom validation rule does.
Outside validation, every failure case has its own exception extending DiscountException: DiscountNotFoundException, DiscountNotActiveException, DiscountNotStartedException, DiscountExpiredException, DiscountUsageLimitReachedException, and MinimumOrderValueException. Each one exposes the discount that failed through getDiscount(), so you can catch the specific case you want to message differently and let the base class handle the rest:
try { $result = LaravelDiscount::applyCode($code, $total, $user);} catch (DiscountExpiredException $e) { return back()->withErrors("Code {$e->getDiscount()->code} has expired.");} catch (DiscountException $e) { return back()->withErrors($e->getMessage());}
Three events cover the lifecycle: DiscountApplied when discounts are applied to an amount, DiscountRedeemed after a redemption transaction commits, and DiscountExpired when validation hits an expired discount. The redeemed event carries both the discount and the usage row, which is enough to drive analytics or a notification without querying again.
Installation
Laravel Discount requires PHP 8.1+ and Laravel 9-13. Install the package via Composer and run the migrations:
composer require binafy/laravel-discountphp artisan migrate
The service provider registers itself, and the migrations create discounts, discount_usages, and discountables. Publishing the config is optional, and only worth doing if you need to change table names, point at a different user model, or adjust code generation defaults:
php artisan vendor:publish --tag="laravel-discount-config"
Two Artisan commands come along with it. discount:generate produces codes from the terminal, and discount:prune deletes expired discounts along with their usage records, which is a reasonable thing to put on the scheduler:
Schedule::command('discount:prune --days=30')->daily();
The full documentation, including the config reference, is on the Laravel Discount GitHub repository.