---
title: "Redeemers & per-customer limits — Coupons for Laravel | Roundly"
description: "Add HasCoupons to your User model, cap uses per customer, read redemption history and support UUID or ULID redeemer keys."
url: https://roundly-consulting.com/open-source/docs/coupons-for-laravel/redeemers
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)

# Redeemers & per-customer limits

Add HasCoupons to your redeemer model — typically User — for a first-class redeemer API:

```php
use Illuminate\Foundation\Auth\User as Authenticatable;
use RoundlyConsulting\Coupons\Concerns\HasCoupons;

class User extends Authenticatable
{
    use HasCoupons;
}
```

```php
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), read on the coupon model’s connection, not this model’s. |
| `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:

```php
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;              // 2
```

The 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 and the per-redeemer cap isn’t enforced — rows written while tracking was on don’t count against it either. 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:

```php
$coupon->redemptions()->with('redeemer')->latest()->get()
    ->map(fn ($r) => [$r->redeemer?->name, (string) $r->discount()]);

$user->couponRedemptions()->count();
```

Redemption rows live beside their coupons — on the connection of the coupons.model class, else the default one — so couponRedemptions() and hasRedeemed() read them where redemption wrote them, also when the coupon or the redeemer model uses a non-default connection.

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

```bash
# .env — set before running the migration
COUPONS_KEY_TYPE=uuid

php artisan migrate
```

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