Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Couponables Laravel Package

michael-rubel/laravel-couponables

Laravel Couponables adds coupon and voucher support to any Eloquent model. Create redeemable codes, attach them polymorphically to users or orders, and enforce usage limits, expiry dates, and per-model redemption rules through a clean, extensible API.

View on GitHub
Deep Wiki
Context7

Getting Started

  1. Install and publish

    composer require michael-rubel/laravel-couponables
    php artisan vendor:publish --tag=couponables-config
    php artisan vendor:publish --tag=couponables-migrations
    php artisan migrate
    

    The package ships two tables: coupons (the discount definitions) and couponables (the polymorphic pivot recording redemptions).

    Version note: 9.0.2 is a compatibility release — it adds Laravel 12 support and tests against PHP 8.4. There are no API changes from 9.0.1, so ^9.0 is fine across Laravel 10/11/12. If you are upgrading from 8.x, verify exception class names and config keys against src/Exceptions and config/couponables.php before assuming drop-in compatibility.

  2. Wire up the traits on both sides of the relationship. There are two distinct traits and mixing them up is the #1 source of confusion:

    • On the redeemer (typically User): MichaelRubel\Couponables\Traits\CanRedeemCoupons
    • On the redeemable (the Product, Plan, Order, whatever the coupon applies to): MichaelRubel\Couponables\Traits\HasCoupons
  3. Look first at: config/couponables.php (model swapping, table names, code generation rules), the Coupon model (fields + helper methods like isExpired(), isExhausted()), and the CanRedeemCoupons trait (the redeemCoupon / canRedeemCoupon API).

  4. First use case — checkout redemption:

    $coupon = Coupon::create([
        'code' => 'SPRING25',
        'type' => CouponType::Percent, // or Fixed
        'value' => 25,
        'expires_at' => now()->addMonth(),
        'quantity' => 100,
        'quantity_per_user' => 1,
    ]);
    
    $user->redeemCoupon('SPRING25', $product);
    

That's the entire happy path. Everything else is guarding it, calculating the discount, and reporting on it.

Implementation Patterns

Guard-then-redeem at checkout. Treat redeemCoupon as a state-mutating write, not a lookup. Validate up front on the cart page with canRedeemCoupon(), then redeem only after payment succeeds:

if (! $user->canRedeemCoupon($code, $product)) {
    throw ValidationException::withMessages(['code' => 'This coupon is no longer valid.']);
}

DB::transaction(function () use ($user, $code, $product) {
    $user->redeemCoupon($code, $product);
    $order->update(['discount' => $discount, 'coupon_code' => $code]);
});

Keep discount math in your app, not the package. type (Fixed / Percent) and value are stored on the coupon, but the package is a redemption ledger — it does not compute order totals for you. Keep a small DiscountCalculator or a method on your Order that takes the coupon and a subtotal, and test it independently. This is a deliberate design decision in the package; don't fight it.

Resolve the coupon before applying. Use $user->getCoupon($code) (returns null if not found) when you need the model — for displaying "25% off" on the cart, or for admin screens. getCoupon is read-only; redeemCoupon writes.

Per-user vs global limits. quantity is the global cap, quantity_per_user is the per-redeemer cap. Both are enforced inside the redeem call by counting existing pivot rows. To surface "2 of 3 uses remaining" in the UI, query the pivot yourself rather than re-calling redeemCoupon:

$used = $user->coupons()->where('coupon_id', $coupon->id)->count();

Polymorphic targeting. The HasCoupons trait lets you attach the same coupon to different domain objects — a Product, a SubscriptionPlan, or even the Order itself. Pick one target model per business rule and stick to it; mixing targets on the same coupon makes the "already redeemed?" check ambiguous.

Admin CRUD. Create coupons as plain Eloquent models. Use code_prefix / code_length in config to auto-generate codes (leave code blank on create), or set it explicitly for marketing campaigns. Store expires_at as a real timestamp and let isExpired() / isNotExpired() drive any "active coupons" listing.

Integration into an existing cart flow:

  • Cart page: canRedeemCoupon() for live validation.
  • Order placement: redeem inside the payment transaction.
  • Refund/cancel: you own the rollback — the package records the redemption, so either soft-cancel the order and treat the row as consumed, or manually detach from the pivot.

Testing. Build a CouponFactory and use RefreshDatabase. Write explicit feature tests for: expired, exhausted globally, exhausted per user, and already-redeemed-on-this-model cases — those are the branches that actually break in production.

Gotchas and Tips

  • Two traits, not one. CanRedeemCoupons on the user, HasCoupons on the redeemable. Putting HasCoupons on User "so coupons work" will silently give you the wrong relation and the wrong counts.
  • redeemCoupon is not idempotent. Calling it twice for the same user/couponable will either throw or double-count depending on your limits config — always wrap in a transaction and always call it once per checkout.
  • Race conditions on quantity. Two simultaneous checkouts can both pass canRedeemCoupon() and both redeem. Wrap redemption in DB::transaction and, if the global quantity is tight, lockForUpdate() the coupon row before checking.
  • Exceptions are the contract. The package throws specific exceptions (CouponNotExistsException, CouponExpiredException, CouponExhaustedException, CouponAlreadyRedeemedException — verify names in src/Exceptions). Catch them and convert to user-facing validation messages rather than letting them bubble to a 500. Names are unchanged in 9.0.2, but check if you skipped a major.
  • Config caching. After editing config/couponables.php in a deployed environment, php artisan config:cache will bite you if you forget to re-run it. Model swaps and table names won't take effect until then.
  • Expiry is timestamp comparison against now(). If your app stores dates in UTC but admins enter local times, isExpired() will disagree with what the admin UI shows. Normalize before saving expires_at.
  • Extension points: the coupon model is swappable via models.coupon in config. If you need extra fields (description, campaign id, minimum order value), publish the migration, add columns, and subclass the package Coupon model rather than modifying vendor code.
  • Add a unique index on couponables (coupon_id, couponable_type, couponable_id) if your business rules forbid ever recording the same coupon twice against the same object — it makes the race in the previous point fail loudly instead of quietly.
  • Debugging redemptions: query the pivot directly (couponables table) — it is the source of truth for "who used what." $user->coupons() and $product->coupons() both return the relation, but only the pivot rows tell you when and against which model.
  • Framework compatibility. With 9.0.2 the package tests against Laravel 12 and PHP 8.4. If you're on Laravel 12 + PHP 8.4, the CI matrix is now exercising that combination — pin ^9.0.2 to get those tested constraints. If you're on an older Laravel/PHP combo, nothing in this release changes behavior, so no migration steps are needed.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast