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();| Assertion | Passes 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 instances of the coupons.model class — 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), and an explicit code already held by a coupon created on the fake or by a live row throws CouponCodeTaken, as in production.
- 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 on the coupons.model class.
- An unknown code behaves like a fresh, unrestricted, zero-value coupon of the coupons.model class: 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: an in-memory coupon with neither activated_at nor expires_at is judged as activated now, so the usage caps still apply. A database row gets the real checks: a seeded coupon nobody activated is refused as Expired, exactly as in production.
- Usage is consumed as in production: every recorded success counts one use of the coupon and, while coupons.redeemer.track is on, one use by the redeemer. A second redemption of a single-use coupon is recorded as a failure with AtMaxUsage, a second one by the same customer of a one-per-customer coupon with AlreadyRedeemed — and check() reports the same.
- 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 testShow 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 cryptoBy 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.