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.
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.
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:
User): MichaelRubel\Couponables\Traits\CanRedeemCouponsProduct, Plan, Order, whatever the coupon applies to): MichaelRubel\Couponables\Traits\HasCouponsLook 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).
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.
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:
canRedeemCoupon() for live validation.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.
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.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.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/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.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.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.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.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.^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.How can I help you explore Laravel packages today?