---
title: "Redeeming coupons — Coupons for Laravel | Roundly"
description: "Validate and redeem in one atomic step — the locked transaction, the fixed check order, RedemptionResult and an exception for every failure."
url: https://roundly-consulting.com/open-source/docs/coupons-for-laravel/redeeming
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Coupons for Laravel](https://roundly-consulting.com/open-source/docs/coupons-for-laravel.md)

# 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:

```php
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 on the coupon model’s connection — 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 and coupons.redeemer.track is on. |

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:

```php
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:

```php
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:

```php
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:

```php
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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/coupons-for-laravel.md)
