Laravel Packages

Laravel Discount: Coupon Codes, Usage Limits, and Stacking

Published
Laravel Discount: Coupon Codes, Usage Limits, and Stacking image

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::Percentage and DiscountType::Fixed, with an optional max_discount_amount cap.
  • Coupon codes: Discounts with a code act as coupons, while codeless discounts apply automatically.
  • Time windows: starts_at and expires_at columns, paired with a valid() query scope.
  • Usage limits: Total usage_limit and per-customer usage_limit_per_user enforcement.
  • Guest support: Pass a session ID to enforce per-user limits for unauthenticated users.
  • Stacking rules: Mark discounts is_stackable and the package works out which combination saves the customer the most.
  • Discountable models: The HasDiscounts trait attaches discounts to any Eloquent model.
  • Cart integration: A CartDiscount service 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.0
LaravelDiscount::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-discount
php 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.

Yannick Lyn Fatt photo

Staff Writer at Laravel News and Full stack web developer.

Sponsored

acquaintsoft logo
Acquaint Softtech

Hire Laravel developers with AI expertise at $20/hr. Get started in 48 hours.

Visit Acquaint Softtech

The latest

View all →
Mock PHP Classes in Tests With the Double Library image

Mock PHP Classes in Tests With the Double Library

Read article
Laravel Truss: Live Interactive Database ER Diagrams image

Laravel Truss: Live Interactive Database ER Diagrams

Read article
Laravel artisan dev: Run Server, Queue, Logs, and Vite image

Laravel artisan dev: Run Server, Queue, Logs, and Vite

Read article
Validate and Convert HEIC Images in Laravel image

Validate and Convert HEIC Images in Laravel

Read article
Saga Lara Flow: Durable Workflows and Compensating Transactions on Laravel Queues image

Saga Lara Flow: Durable Workflows and Compensating Transactions on Laravel Queues

Read article
Reject Unexpected Array Keys with Laravel Validation image

Reject Unexpected Array Keys with Laravel Validation

Read article