Redeeming coupons
Every redemption goes through CouponManager, which runs RedeemCouponAction — Coupons::code()->redeem(), the flat Coupons::redeem(), and the $coupon->redeemBy() and $user->redeemCoupon() shorthands alike. It validates eligibility, increments usage atomically, records a per-redeemer row when tracking is on, and dispatches CouponRedeemed. With SAVE20 (20 % off) on a 50.00 EUR basket:
use RoundlyConsulting\Coupons\Facades\Coupons;
use RoundlyConsulting\Money\Money;
$result = Coupons::code('SAVE20')->redeem(Money::ofMinor(5000, 'EUR'), $user);
$result->coupon; // the refreshed Coupon, usage already incremented
$result->discount; // Money 10.00 EUR — never more than the price
$result->total; // Money 40.00 EUR — the new total after the discount
$result->redeemer; // the redeemer you passed, or null
$result->freeShipping; // true for a free-shipping coupon — zero your own shipping line
// The flat form of the same call:
$result = Coupons::redeem('SAVE20', Money::ofMinor(5000, 'EUR'), redeemer: $user);
// Model shorthand — sugar over the same manager (redeemer may be null for guest checkout):
$result = $coupon->redeemBy($user, Money::ofMinor(5000, 'EUR'));What happens on redeem
- Resolves the coupon by model or code, matched case-insensitively. An unknown or blank code dispatches CouponRedemptionFailed and throws CouponNotFound.
- Opens a transaction on the coupon model’s own connection and re-reads the coupon row with lockForUpdate(), so concurrent redemptions can never exceed max_usage — also when coupons.model lives on a non-default connection. A soft-deleted Coupon isn’t found here and throws CouponNotFound.
- Runs the eligibility checks in a fixed order and throws on the first failure.
- Computes the discount with discountFor() and the total as the price minus the discount.
- Increments usage with a relative write and refreshes the model.
- Writes a coupon_redemptions row — only when a redeemer is given and coupons.redeemer.track is on. Guest redemptions count toward the global cap only.
- Dispatches CouponRedeemed, plus CouponExhausted exactly once on the redemption that brings usage up to a positive max_usage — both after the outermost transaction commits.
Check order
The checks always run in the same order, so a customer always gets the same answer:
| # | RedemptionFailureReason | Exception | Meaning |
|---|---|---|---|
| 1 | NotFound | CouponNotFound | An unknown or blank code, or a soft-deleted Coupon instance. |
| 2 | CurrencyMismatch | CurrencyMismatch | The coupon is locked to another currency than the price. |
| 3 | MinimumSpendNotMet | MinimumSpendNotMet | The price is below the coupon’s minimum spend. |
| 4 | Expired | CouponExpired | Not yet active or past its expiry. |
| 5 | AtMaxUsage | CouponAtMaxUsage | The global usage cap is reached. |
| 6 | AlreadyRedeemed | CouponAlreadyRedeemed | The per-redeemer cap is reached — checked only when a redeemer is given. |
redeem() always throws on a failure — there is no non-throwing redemption. To ask first, Coupons::check() returns the first failing RedemptionFailureReason, or null, without throwing, locking or writing; isRedeemableBy() and the Redeemable rule run the same checks.
Handling failures
Every failure is a precise, catchable exception. All of them extend CouponNotRedeemable except CouponNotFound; both extend CouponException:
use RoundlyConsulting\Coupons\Exceptions\{CouponNotFound, CouponExpired, CouponAtMaxUsage,
CouponAlreadyRedeemed, MinimumSpendNotMet, CurrencyMismatch};
try {
$result = Coupons::redeem($code, $price, redeemer: $user);
} catch (CouponNotFound) { // unknown code
} catch (CouponExpired) { // not active / past expiry
} catch (CouponAtMaxUsage) { // global cap reached
} catch (CouponAlreadyRedeemed) { // per-redeemer cap reached
} catch (MinimumSpendNotMet) { // price below the coupon's minimum
} catch (CurrencyMismatch) { // coupon locked to another currency
}Or catch the two base types at once:
use RoundlyConsulting\Coupons\Exceptions\CouponNotFound;
use RoundlyConsulting\Coupons\Exceptions\CouponNotRedeemable;
try {
$result = Coupons::redeem((string) $request->input('code'), $cartTotal, $request->user());
} catch (CouponNotFound|CouponNotRedeemable $e) {
return back()->withErrors(['code' => $e->getMessage()]);
}Free shipping
The package owns no cart or shipping line, so a free-shipping coupon discounts nothing from the goods price — discount is zero and total equals the price. Zero your own shipping when $result->freeShipping (or $coupon->appliesToShipping()) is true:
use RoundlyConsulting\Money\Money;
$result = Coupons::redeem('SHIPFREE', $cartTotal, redeemer: $user);
$result->discount; // zero — the goods price is untouched
$result->total; // equals $cartTotal
if ($result->freeShipping) {
$shipping = Money::zero($shipping->currency()); // zero your own shipping line
}Calling the action directly
The same redemption without the facade or the manager — see DI and actions:
use RoundlyConsulting\Coupons\Actions\RedeemCouponAction;
use RoundlyConsulting\Coupons\DataTransferObjects\RedeemCouponData;
$result = app(RedeemCouponAction::class)->execute(
new RedeemCouponData(coupon: 'SAVE20', price: $cartTotal, redeemer: $user),
);Show 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.