Laravel Discount: Coupon Codes, Usage Limits, and Stacking
Laravel Composer Pacakge #Laravel #Packages #E-commerce #Coupons #Discounts

Laravel Discount: Coupon Codes, Usage Limits, and Stacking

4 min read Mohamed Said Mohamed Said

Overview

Building a discount system from scratch in Laravel means writing the same percentage-vs-fixed logic, expiry checks, and race-condition-safe redemption code over and over. Laravel Discount by Milwad Khosravi packages all of that into Eloquent models evaluated through a single LaravelDiscount facade.

Installation

The package requires PHP 8.1+ and Laravel 9–13.

composer require binafy/laravel-discount
php artisan migrate

Migrations create discounts, discount_usages, and discountables tables. Publish the config only if you need to change table names, the user model, or code-generation defaults:

php artisan vendor:publish --tag="laravel-discount-config"

Percentage and Fixed Discounts

A discount is a standard Eloquent model. Apply it through the facade and receive a DiscountResult:

use Binafy\LaravelDiscount\Enums\DiscountType;
use Binafy\LaravelDiscount\Models\Discount;
use Binafy\LaravelDiscount\Facades\LaravelDiscount;

$discount = Discount::query()->create([
    'name'  => 'Summer Sale',
    'type'  => DiscountType::Percentage,
    'value' => 20,
]);

$result = LaravelDiscount::apply($discount, 200);
// originalAmount: 200.0 | discountAmount: 40.0 | payableAmount(): 160.0

Add max_discount_amount to cap a percentage discount — the classic "20% off, up to $100" scenario — without hardcoding anything in a controller.

Coupon Codes, Expiry, and Usage Limits

Setting a code column turns a discount into a promotional coupon. applyCode() validates the code and throws DiscountNotFoundException on a miss:

$result = LaravelDiscount::applyCode('WELCOME10', 200, $user);

Bulk codes are generated with random_int(), stripping ambiguous characters like 0/O and 1/I:

LaravelDiscount::generateCode();             // "8FJ2K9QW"
LaravelDiscount::generateCodes(100, 'VIP'); // Collection of 100 unique codes

Time windows use starts_at and expires_at. A valid() query scope filters to currently active discounts. Applying outside the window throws typed exceptions (DiscountNotStartedException, DiscountExpiredException) and fires a DiscountExpired event.

Usage limits are enforced at redemption time, not application time, using a database-level increment inside a transaction:

LaravelDiscount::redeem($discount, $user, $result->discountAmount);

If two customers claim the last redemption simultaneously, only one succeeds — the database decides. Guests are tracked via session ID instead of a user model.

Stackable Discounts and Model Attachment

Mark a discount is_stackable and applyMany() resolves the optimal combination automatically: it sums all stackable discounts, finds the single best non-stackable one, and returns whichever side saves more.

$result = LaravelDiscount::applyMany([$tenPercent, $tenFixed, $bigSolo], 100);
// $result->discounts  — the codes that actually applied
// $result->discountAmount — the winning total

The HasDiscounts trait attaches discounts to any Eloquent model via a polymorphic relationship:

class Product extends Model
{
    use HasDiscounts;
}

$product->discounts()->attach($discount);
$result = $product->applyDiscounts($product->price);

Cart Integration

Install the companion binafy/laravel-cart package and a CartDiscount service handles cart-level and item-level discounts:

$cartDiscount->applyToCart($cart, 'SUMMER-8FJ2K9QW');  // cart total
$cartDiscount->applyToItem($cartItem, $discount);       // single line item
$cartDiscount->applyItemDiscounts($cart);               // all items via HasDiscounts

Validation and Exceptions

A ValidDiscountCode form-request rule rejects invalid codes before any business logic runs, returning a specific failure reason rather than a generic message. Every failure case has its own exception extending DiscountException, each exposing the failing discount via getDiscount():

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 lifecycle events — DiscountApplied, DiscountRedeemed, and DiscountExpired — carry enough data to drive analytics without additional queries.

Key Takeaways

  • Two discount types (Percentage, Fixed) with an optional max_discount_amount cap.
  • Race-condition-safe redemption via database-level transactional increments.
  • Guest support through session ID tracking alongside nullable user_id.
  • Stacking resolver automatically picks the combination that saves the customer the most.
  • HasDiscounts trait attaches discounts polymorphically to any Eloquent model.
  • Artisan commands discount:generate and discount:prune for terminal and scheduler use.

Source: Laravel Discount: Coupon Codes, Usage Limits, and Stacking — Laravel News

Found this useful?

Frequently Asked Questions

3 questions
Q01 How does Laravel Discount prevent two users from claiming the last redemption at the same time?
Redemption runs inside a database transaction that increments `used_count` only when the current count is still below the limit in the WHERE clause. If two requests arrive simultaneously, the database allows only one increment, and the other receives a `DiscountUsageLimitReachedException`.
Q02 How does the stacking resolver decide which discounts to apply?
`applyMany()` drops any discount that fails validation, splits the rest into stackable and non-stackable groups, sums the stackable ones (capped at the order total), finds the single best non-stackable discount, and returns whichever side produces the larger saving.
Q03 Can Laravel Discount be used for guest (unauthenticated) users?
Yes. Pass a session ID instead of a user model to both `applyCode()` and `redeem()`. The package tracks guest usage in the `session_id` column of the `discount_usages` table alongside the nullable `user_id`.

Continue reading

More Articles

View all