NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Coupons for Laravel

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:

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')));
ConstructorResult
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():

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:

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():

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

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

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