---
title: "Creating coupons — Coupons for Laravel | Roundly"
description: "Create fixed, percentage and free-shipping coupons with CreateCouponData — named constructors, caps, minimum spend and validation rules."
url: https://roundly-consulting.com/open-source/docs/coupons-for-laravel/creating-coupons
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)

# Creating coupons

Create coupons through the facade — Coupons::create() with a CreateCouponData DTO, or the Coupons::generate() shortcut. Both run CreateCouponAction. A unique code is generated when you don’t supply one, and CouponCreated is dispatched.

## Named constructors

The named constructors pick the unit and the currency lock for you:

```php
use RoundlyConsulting\Coupons\DataTransferObjects\CreateCouponData;
use RoundlyConsulting\Coupons\Facades\Coupons;
use RoundlyConsulting\Money\Money;

// 5.00 EUR off, first 500 uses, basket must reach 20.00 EUR
Coupons::create(CreateCouponData::fixed(
    Money::ofMinor(500, 'EUR'), code: 'FIVE', maxUsage: 500, minimumSpend: Money::ofMinor(2000, 'EUR'),
));

// 12.5 % off, capped at 50.00 EUR (locks the coupon to EUR)
Coupons::create(CreateCouponData::percentage('12.5', code: 'SPRING', maxDiscount: Money::ofMinor(5000, 'EUR')));

// Free shipping over 40.00 EUR
Coupons::create(CreateCouponData::freeShipping('SHIPFREE', minimumSpend: Money::ofMinor(4000, 'EUR')));
```

| Constructor | Result |
| --- | --- |
| `CreateCouponData::fixed(Money $amount, ?string $code = null, int $maxUsage = 0, ?Money $minimumSpend = null)` | Fixed coupon: value = the amount’s minor units, locked to its currency. Throws money’s AmountOverflow past int64 minor units. |
| `CreateCouponData::percentage(Percentage\|int\|string $percent, ?string $code = null, ?Money $maxDiscount = null, int $maxUsage = 0, ?Money $minimumSpend = null)` | Percentage coupon from 25, '12.5' or a Percentage, stored in whole basis points — '12.345' throws money’s RoundingNecessary. Locked to the cap’s currency, else the minimum spend’s, when either is given. |
| `CreateCouponData::freeShipping(?string $code = null, int $maxUsage = 0, ?Money $minimumSpend = null)` | Free-shipping coupon; locked to the minimum spend’s currency when one is given. |

## The full DTO

For full control, construct CreateCouponData yourself and pass it to Coupons::create():

```php
use RoundlyConsulting\Coupons\DataTransferObjects\CreateCouponData;
use RoundlyConsulting\Coupons\Enums\DiscountType;
use RoundlyConsulting\Coupons\Facades\Coupons;
use RoundlyConsulting\Money\Currency;
use RoundlyConsulting\Money\Money;

$coupon = Coupons::create(new CreateCouponData(
    type: DiscountType::Percentage,
    value: 2000,                         // basis points: 20 % off
    currency: Currency::of('EUR'),       // optional lock (required for Fixed / min spend / cap)
    code: 'SAVE20',                      // optional — auto-generated when omitted
    maxUsage: 100,                       // optional — 0 means unlimited
    minimumSpend: Money::ofMinor(5000, 'EUR'),
    maxDiscount: Money::ofMinor(1000, 'EUR'),
));
```

lockedCurrency() returns the currency the coupon will be locked to: the explicit currency, else the minimum spend’s, else the cap’s, else null.

## Validation

- Blank explicit code ('' or only whitespace) — InvalidCouponDefinition.
- Fixed coupon without a currency — InvalidCouponDefinition.
- Negative fixed value — InvalidCouponDefinition.
- Percentage outside 0..10 000 basis points — InvalidCouponDefinition.
- Percentage finer than whole basis points, e.g. CreateCouponData::percentage('12.345') — money’s RoundingNecessary.
- Minimum spend or cap in another currency than the lock — money’s CurrencyMismatch.
- CreateCouponData::fixed() with an amount beyond int64 minor units — money’s AmountOverflow (value is a bigint column).
- Explicit code a live coupon already holds, in any case — CouponCodeTaken.
- A code that must be generated but can’t be — InvalidCouponConfiguration (see Generated codes).

## Unique codes and reuse

Every code is stored trimmed and upper-cased, and a code is unique among coupons that are not soft-deleted. An explicit code a live coupon already holds, in any case, throws CouponCodeTaken, a CouponException — also when a concurrent request takes it between the check and the insert. A code that only soft-deleted coupons hold is free: after Coupons::prune() trashes last season’s XMAS, the next generate(code: 'XMAS') creates a fresh coupon, and the old row keeps its code and redemption history:

```php
use RoundlyConsulting\Coupons\Enums\DiscountType;
use RoundlyConsulting\Coupons\Exceptions\CouponCodeTaken;
use RoundlyConsulting\Coupons\Facades\Coupons;

Coupons::generate(DiscountType::Percentage, 1000, code: 'SUMMER');

try {
    Coupons::generate(DiscountType::Percentage, 1000, code: ' summer ');
} catch (CouponCodeTaken $e) {
    $e->getMessage(); // "Coupon code [SUMMER] is already taken."
}

// After Coupons::prune() soft-deletes last season's XMAS, its code is free again:
Coupons::generate(DiscountType::Percentage, 1000, code: 'XMAS'); // a fresh coupon; the old row keeps its history
```

Restoring a trashed coupon whose code a live coupon now holds fails with the database’s unique-constraint error. The uniqueness is enforced by the database — a unique index on a generated column that holds the code only while the row is not trashed — which needs MySQL/MariaDB, PostgreSQL or SQLite.

## Activation, windows and limits

CreateCouponData carries the type, value, currency, code, global cap, minimum spend and discount cap. Activation, expiry, the per-redeemer cap and free-form meta are set on the model afterwards. The fluent mutators don’t save — call save():

```php
$coupon->activate()->save();                                            // redeemable from now
$coupon->activate(now()->addDay())->expire(now()->addMonth())->save();  // a scheduled window
$coupon->setMaxUsageTo(1000)->save();                                   // global cap (0 = unlimited)
$coupon->update(['max_usage_per_redeemer' => 1]);                       // one per customer
$coupon->update(['meta' => ['campaign' => 'spring']]);                  // free-form, cast to a collection
```

## Seeders and fixtures

createQuietly() — or execute($data, quiet: true) on the action — persists the coupon without dispatching CouponCreated:

```php
use RoundlyConsulting\Coupons\Actions\CreateCouponAction;
use RoundlyConsulting\Coupons\DataTransferObjects\CreateCouponData;
use RoundlyConsulting\Coupons\Facades\Coupons;

// Seeders: no CouponCreated event
Coupons::createQuietly(CreateCouponData::percentage(10, 'SEED10'));

// The same through the action
app(CreateCouponAction::class)->execute($data, quiet: true);
```

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