Redeemers & per-customer limits
Add HasCoupons to your redeemer model — typically User — for a first-class redeemer API:
use Illuminate\Foundation\Auth\User as Authenticatable;
use RoundlyConsulting\Coupons\Concerns\HasCoupons;
class User extends Authenticatable
{
use HasCoupons;
}use RoundlyConsulting\Money\Money;
$result = $user->redeemCoupon('SAVE20', Money::ofMinor(5000, 'EUR')); // RedemptionResult
$history = $user->couponRedemptions; // morphMany history
$used = $user->hasRedeemed('SAVE20'); // bool (tracked only)| Method | Description |
|---|---|
couponRedemptions(): MorphMany | This model’s tracked redemptions (CouponRedemption). |
redeemCoupon(Coupon|string $coupon, Money $cartTotal): RedemptionResult | Redeem as this model against the cart total, through CouponManager — the same path as the facade, so Coupons::fake() records it. |
hasRedeemed(string $code): bool | Whether a tracked redemption of the live coupon holding that code exists — matched case-insensitively; a pruned coupon that once held the code doesn’t count. |
redeemCoupon() is sugar over the same manager as Coupons::redeem(), so Coupons::fake() records it. It requires the cart total, in the cart’s own currency: the discount comes off it, the currency lock and minimum spend are checked against it, and a redemption without a price would consume a use — the customer’s only one, on a single-use coupon — at a zero discount.
hasRedeemed() asks about the live coupon holding the code, matched case-insensitively: once a pruned XMAS is issued again, last season’s redemption doesn’t count for the new one.
One per customer
max_usage_per_redeemer caps how often a single redeemer can use a coupon (0 = unlimited) while everyone else can still use the code:
use RoundlyConsulting\Money\Money;
$coupon->update(['max_usage_per_redeemer' => 1]);
$coupon->redeemBy($ada, Money::ofMinor(5000, 'EUR')); // ok
$coupon->redeemBy($ada, Money::ofMinor(5000, 'EUR')); // throws CouponAlreadyRedeemed
$coupon->redeemBy($lin, Money::ofMinor(5000, 'EUR')); // ok — the cap is per redeemer
$coupon->usageBy($ada); // 1
$coupon->isAtMaximumUsageFor($ada); // true
$coupon->remainingUsageFor($lin); // 0
$coupon->fresh()->usage; // 2The per-redeemer cap is checked only when a redeemer is passed. Guest checkouts (redeemer null) are supported and count toward the global cap only.
Tracking
Per-redeemer caps, usageBy(), remainingUsageFor() and hasRedeemed() read the coupon_redemptions table, which is written only while coupons.redeemer.track is on (the default; env-style values such as 1 or off work). Turn it off and no rows are written: the global cap still applies and remainingUsageFor() returns null.
Redemption history
Each tracked redemption is a CouponRedemption — final, soft-deletable — with coupon(), redeemer() and discount(), the recorded amount_discounted as Money. coupon() hydrates the model configured in coupons.model:
$coupon->redemptions()->with('redeemer')->latest()->get()
->map(fn ($r) => [$r->redeemer?->name, (string) $r->discount()]);
$user->couponRedemptions()->count();UUID and ULID redeemers
The redeemer is a nullable polymorphic column whose key type follows coupons.key_type: bigint (default), uuid or ulid. All redeemer models must share one key type, and the value is read by the migration — set it before you migrate:
# .env — set before running the migration
COUPONS_KEY_TYPE=uuid
php artisan migrateShow your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.