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

Coupons::fake() swaps the manager — for the facade and for every injected CouponManager — with CouponsFake, a recorder that writes nothing: no rows and no events. It returns the fake for assertions. The Coupon model, the HasCoupons trait, the Redeemable rule and both Artisan commands go through the manager too, so redemptions made through $coupon->redeemBy() and $user->redeemCoupon() are recorded as well:

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

$fake = Coupons::fake();

Coupons::generate(DiscountType::Fixed, 500, 'TEN', currency: 'USD');
$result = Coupons::code('TEN')->redeem(Money::ofMinor(5000, 'USD'), $user);
$result->total;                                                   // 45.00 USD — the real discount math, no row written
$user->redeemCoupon('WELCOME', Money::ofMinor(2000, 'USD'));      // recorded too

$fake->assertCreated(fn ($coupon) => $coupon->code === 'TEN');
$fake->assertRedeemed('TEN', fn ($result) => $result->redeemer?->is($user));
$fake->assertRedeemed('WELCOME');
$fake->assertNothingRevoked();

Every assertion

$fake->assertCreated();                                             // or a callback: fn (Coupon $c) => …
$fake->assertNothingCreated();
$fake->assertRedeemed('SUMMER');                                    // by code
$fake->assertRedeemed('SUMMER', fn ($result) => /* ... */ true);    // by code + callback
$fake->assertRedeemed(callback: fn ($result) => /* ... */ true);    // by callback only
$fake->assertNotRedeemed('OTHER');
$fake->assertNothingRedeemed();
$fake->assertRedemptionFailed('OLD', RedemptionFailureReason::Expired); // reason optional; a string works too
$fake->assertRevoked('SUMMER');                                     // code optional
$fake->assertExpiredAll();
$fake->assertNothingRevoked();                                      // no revoke() and no expireAll()
$fake->assertPruned(days: 30, force: false);                        // both optional
$fake->assertNothingPruned();
AssertionPasses when
assertCreated(?callable $callback = null)Any coupon — or one the callback accepts — was created.
assertNothingCreated()No coupon was created.
assertRedeemed(?string $code = null, ?callable $callback = null)Any successful redemption, or one matching the code and/or callback. Callback alone: assertRedeemed(callback: fn ($result) => …).
assertNotRedeemed(string $code)No successful redemption of that code.
assertNothingRedeemed()No successful redemptions at all.
assertRedemptionFailed(string $code, RedemptionFailureReason|string|null $reason = null)A refused attempt for that code — optionally for that reason, as the enum or its value.
assertRevoked(?string $code = null)Any coupon — or the one holding the code — was revoked via revoke() or code()->revoke().
assertExpiredAll()expireAll() was called.
assertNothingRevoked()Neither revoke() nor expireAll() was called.
assertPruned(?int $days = null, ?bool $force = null)prune() was called — optionally with that window and/or mode.
assertNothingPruned()prune() was not called.

How the fake behaves

  • generate(), create() and createQuietly() return unsaved Coupon models — code FAKE-1, FAKE-2, … when none is given — record them and dispatch no events. They refuse the same invalid definitions as the real ones (InvalidCouponDefinition), but don’t check that a code is taken.
  • Reads check the coupons created on the fake first, then the database: find(), findOrFail() and exists(). Codes match case-insensitively, as in production. redeemable() returns an always-empty builder.
  • An unknown code behaves like a fresh, unrestricted, zero-value coupon: check() returns null, preview() returns zero and redeem() records a success — check-then-redeem flows work without seeding.
  • Coupons created on the fake count as active too. A database row gets the real checks: a seeded coupon nobody activated is refused as Expired, exactly as in production.
  • redeem() never throws. A success returns the discount and total the real redemption would compute — a 20 % coupon on 50.00 EUR gives discount 10.00 and total 40.00; a refusal returns a zero discount and the full price, is recorded only as a failure and never counts as redeemed.
  • revoke() expires the coupon in memory without saving and records it; expireAll() records the call and expires matching live coupons created on the fake; prune() records its arguments and returns 0.

To test a refusal, use a coupon that would be refused — seed it on the fake with Coupons::generate(), as a row, or pass a model as here:

use RoundlyConsulting\Coupons\Enums\RedemptionFailureReason;
use RoundlyConsulting\Coupons\Facades\Coupons;
use RoundlyConsulting\Coupons\Models\Coupon;
use RoundlyConsulting\Money\Money;

$fake = Coupons::fake();

$expired = Coupon::factory()->percentage(1000)->expired()->make(['code' => 'OLD']);
Coupons::redeem($expired, Money::ofMinor(1000, 'EUR'));   // recorded as a failure, never throws

$fake->assertRedemptionFailed('OLD', RedemptionFailureReason::Expired);  // or 'expired'
$fake->assertNotRedeemed('OLD');

The Artisan commands inject the manager too, so the fake records them:

use RoundlyConsulting\Coupons\Facades\Coupons;

$fake = Coupons::fake();

$this->artisan('coupons:expire', ['--code' => 'SUMMER']);
$this->artisan('coupons:prune', ['--days' => 90, '--force' => true]);

$fake->assertExpiredAll();
$fake->assertPruned(days: 90, force: true);

Factories

Coupon::factory() ships states for every coupon shape; CouponRedemption::factory() is available too:

use RoundlyConsulting\Coupons\Models\Coupon;
use RoundlyConsulting\Money\Money;

Coupon::factory()->active()->fixed(500, 'EUR')->withMinimumSpend(Money::ofMinor(2000, 'EUR'))->create();
Coupon::factory()->active()->percentage(2500)->create(['code' => 'QUARTER']);
Coupon::factory()->active()->cappedPercentage(5000, Money::ofMinor(300, 'EUR'))->create();
Coupon::factory()->freeShipping()->expired()->create();
  • active() / expired() — activated or expired an hour ago.
  • fixed(int $value = 100, ?string $currency = null) — locked to the currency, coupons.default_currency by default.
  • percentage(int $basisPoints = 1000) — unlocked.
  • freeShipping().
  • cappedPercentage(int $basisPoints = 2500, ?Money $maxDiscount = null) — the cap defaults to 500 minor units of the default currency.
  • withMinimumSpend(Money $minimumSpend) and forCurrency(string $currency) — both lock the currency.

Against the real database

For end-to-end tests, publish the migrations into your app so RefreshDatabase creates both tables, then assert through the real flow:

use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Coupons\Events\CouponExhausted;
use RoundlyConsulting\Coupons\Exceptions\CouponAtMaxUsage;
use RoundlyConsulting\Coupons\Facades\Coupons;
use RoundlyConsulting\Coupons\Models\Coupon;
use RoundlyConsulting\Money\Money;

it('sells out after the last use', function () {
    Event::fake([CouponExhausted::class]);

    Coupon::factory()->active()->fixed(500, 'EUR')->create(['code' => 'ONCE', 'max_usage' => 1]);

    $result = Coupons::redeem('ONCE', Money::ofMinor(5000, 'EUR'));
    expect($result->total->minor())->toBe('4500');

    expect(fn () => Coupons::redeem('ONCE', Money::ofMinor(5000, 'EUR')))
        ->toThrow(CouponAtMaxUsage::class);

    Event::assertDispatchedTimes(CouponExhausted::class, 1);
});

The package’s own suite runs with:

composer test

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.