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

The Coupons facade

RoundlyConsulting\Coupons\Facades\Coupons is the entry point for everything the package does. Its root is CouponManager, an injectable singleton — see DI and actions to use it without the facade. The full surface:

use RoundlyConsulting\Coupons\Enums\DiscountType;
use RoundlyConsulting\Coupons\Facades\Coupons;

$coupon = Coupons::generate(DiscountType::Percentage, value: 2000, code: 'SUMMER', maxUsage: 100); // 20 %
$coupon = Coupons::generate(DiscountType::Fixed, value: 500, code: 'FIVE', currency: 'EUR');       // 5.00 EUR off
$coupon = Coupons::create($data);         // from a CreateCouponData
$coupon = Coupons::createQuietly($data);  // without dispatching CouponCreated (seeders, fixtures)

// One coupon, by code or model:
Coupons::code('SUMMER')->check($cart, $user);   // ?RedemptionFailureReason — null = redeemable
Coupons::code('SUMMER')->preview($cart);        // Money — the discount, without redeeming
Coupons::code('SUMMER')->redeem($cart, $user);  // RedemptionResult
Coupons::code('SUMMER')->revoke();              // expire it now (reversible), fires CouponRevoked

// The same verbs, flat:
Coupons::check('SUMMER', $cart, $user);
Coupons::preview($coupon, $cart);
Coupons::redeem('SUMMER', $cart, redeemer: $user);
Coupons::revoke($coupon);                 // a Coupon or a code

// Lookups:
Coupons::find('SUMMER');                  // ?Coupon
Coupons::findOrFail('SUMMER');            // throws CouponNotFound
Coupons::exists('SUMMER');                // bool
Coupons::redeemable()->get();             // query builder of coupons redeemable right now

// Maintenance (the console commands call these):
Coupons::expireAll();                     // revoke every live coupon; returns the count
Coupons::expireAll(code: 'SUMMER');       // only the live coupon holding that code
Coupons::prune(days: 30);                 // soft-delete coupons expired 30+ days ago; returns the count
Coupons::prune(days: 30, force: true);    // delete them permanently, previously trashed ones included

Every method

MethodReturnsDescription
generate()CouponQuick creation from a type, value, optional code, max usage and currency. value is minor units (Fixed, needs a currency) or basis points (Percentage). Fires CouponCreated.
create()CouponCreate from a CreateCouponData DTO; fires CouponCreated. An explicit code a live coupon already holds throws CouponCodeTaken.
createQuietly()CouponThe same, without dispatching CouponCreated — for seeders and fixtures.
find()?CouponLook up by code — case-insensitive, surrounding whitespace ignored.
findOrFail()CouponLook up or throw CouponNotFound.
exists()boolWhether a coupon with that code exists.
redeemable()Builder<Coupon>Query of coupons redeemable right now — active, not expired, not exhausted.
code()CouponCodeA handle on one coupon, by code or model, with check(), preview(), redeem() and revoke().
check()?RedemptionFailureReasonThe first reason redemption would refuse the coupon, or null. Takes an optional price and redeemer. Never throws, locks or writes; an unknown code reports NotFound.
preview()MoneyThe discount for a price without redeeming — zero on a currency mismatch. Throws CouponNotFound for an unknown code.
redeem()RedemptionResultValidate and redeem atomically by code or model, with an optional redeemer.
revoke()CouponReversibly revoke a coupon or code: expires_at = now, saved, CouponRevoked fired. Throws CouponNotFound for an unknown code.
expireAll()intRevoke every live coupon — or only the live one holding the given code — and return the count. Backs coupons:expire.
prune()intDelete coupons that expired at least $days days ago (default 30) — soft by default, permanently with $force — and return the count. Backs coupons:prune; a negative window throws InvalidArgumentException.
fake()CouponsFakeSwap the manager — for the facade and every injected CouponManager — for a recorder that writes nothing, and return it for assertions.

The code() handle

Coupons::code() takes a code or a Coupon model and returns a CouponCode handle. Each of its methods delegates to the flat verb of the same name, so the two forms are interchangeable and the fake records both:

MethodReturnsSame as
check(?Money $price = null, ?Model $redeemer = null)?RedemptionFailureReasonCoupons::check($coupon, $price, $redeemer)
preview(Money $price)MoneyCoupons::preview($coupon, $price)
redeem(Money $price, ?Model $redeemer = null)RedemptionResultCoupons::redeem($coupon, $price, $redeemer)
revoke()CouponCoupons::revoke($coupon)

Checking before checkout

check() answers “why won’t this code work?” before checkout. It runs the same checks, in the same order, as redeem() and returns the first failing RedemptionFailureReason — NotFound, CurrencyMismatch, MinimumSpendNotMet, Expired, AtMaxUsage or AlreadyRedeemed — or null. Without a price it skips the currency and minimum-spend checks; without a redeemer it skips the per-redeemer cap. translationKey() gives you the same message the validation rule shows:

use RoundlyConsulting\Coupons\Facades\Coupons;

if ($reason = Coupons::code($request->code)->check($cart, $request->user())) {
    return back()->withErrors(['code' => __($reason->translationKey(), ['code' => $request->code])]);
}

$discount = Coupons::code($request->code)->preview($cart); // show it before the order is placed

preview() never throws on a currency mismatch — it returns zero — but it does throw CouponNotFound for an unknown code. redeem() and revoke() accept a code or a Coupon. Every lookup matches codes case-insensitively and ignores surrounding whitespace, so summer and SUMMER are the same coupon; a blank code and a soft-deleted Coupon instance are NotFound for check(), just as redeem() throws CouponNotFound for them.

New coupons start inactive

A new coupon has no activated_at, so redeeming it throws CouponExpired until you activate it with $coupon->activate()->save() — or pass a future CarbonInterface to schedule it.

Revoking

revoke() is a reversible kill-switch: it sets expires_at to now — the row is not deleted and its history stays intact — and fires CouponRevoked. Put the coupon back in circulation by moving or clearing expires_at:

Coupons::revoke('SAVE20');                        // expires_at = now, fires CouponRevoked

$coupon = Coupons::findOrFail('SAVE20');
$coupon->expire(now()->addMonth())->save();       // back in circulation until next month
$coupon->update(['expires_at' => null]);          // or: no expiry at all

expireAll() does the same for every live coupon — or only the live one holding a code — and returns the count. prune() soft-deletes coupons that expired a given number of days ago — their codes are free to issue again — or deletes them permanently with force: true, and refuses a negative window with an InvalidArgumentException, because a window in the future would delete live coupons. Both back the Artisan commands.

createQuietly() creates a coupon without dispatching CouponCreated — useful for seeders and fixtures that shouldn’t trigger your listeners.

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.