impruthvi / cashier-dunning
Prove your Laravel dunning actually works: replay real Stripe billing lifecycles with no Stripe account.
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/console: ^11.0||^12.0||^13.0
- illuminate/container: ^11.0||^12.0||^13.0
- illuminate/contracts: ^11.0||^12.0||^13.0
- illuminate/database: ^11.0||^12.0||^13.0
- illuminate/http: ^11.0||^12.0||^13.0
- illuminate/mail: ^11.0||^12.0||^13.0
- illuminate/notifications: ^11.0||^12.0||^13.0
- illuminate/queue: ^11.0||^12.0||^13.0
- illuminate/support: ^11.0||^12.0||^13.0
- laravel/cashier: ^16.0
- nesbot/carbon: ^2.72||^3.0
- spatie/laravel-package-tools: ^1.16
- stripe/stripe-php: ^17.4||^18.0||^19.0||^20.0||^21.0
- symfony/console: ^7.0||^8.0
- symfony/http-foundation: ^7.0||^8.0
- symfony/mime: ^7.0||^8.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^9.13||^10.0.0||^11.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-20 21:17:08 UTC
README
cashier-dunning
Replay real Stripe billing lifecycles with no Stripe account.
Failed payments are where SaaS revenue quietly leaks, and dunning is the code least likely to be tested — because testing it properly means waiting a month for a card to fail three times.
This package records a real Stripe billing lifecycle once, then replays it in under a second, forever, with no Stripe account, no API key and no network.
$ php artisan billing:simulate trial-dunning-cancel-reactivate --explain
trial-dunning-cancel-reactivate replayed with no Stripe account
ok +0d trial starts
invoice.finalized 200
invoice.created 200
invoice.paid 200
customer.subscription.created 200
api: false -> true (at customer.subscription.created)
projects: 0 -> 10 (at customer.subscription.created)
teams: false -> true (at customer.subscription.created)
invoice.payment_succeeded 200
ok +11d three days before the trial ends
customer.subscription.trial_will_end 200
ok +14d1h trial ends, first payment attempt fails
invoice.created 200
customer.subscription.updated 200
invoice.finalized 200
customer.subscription.updated 200
invoice.payment_failed 200
ok +17d1h grace period expires on the second failed attempt
invoice.payment_failed 200
ok +28d retries continue, access holds
invoice.payment_failed 200
invoice.payment_failed 200
invoice.payment_failed 200
invoice.payment_failed 200
invoice.payment_failed 200
invoice.payment_failed 200
ok +31d retries exhausted, subscription cancelled
invoice.payment_failed 200
api: true -> false (at invoice.payment_failed)
projects: 10 -> 0 (at invoice.payment_failed)
teams: true -> false (at invoice.payment_failed)
customer.subscription.deleted 200
ok +34d customer fixes their card and resubscribes
invoice.finalized 200
invoice.paid 200
customer.subscription.created 200
api: false -> true (at customer.subscription.created)
projects: 0 -> 10 (at customer.subscription.created)
teams: false -> true (at customer.subscription.created)
invoice.created 200
invoice.payment_succeeded 200
Side effects what the application did
mail:replay@example.test ×9
9 outbound delivery(s) blocked. A replay never mails a real customer.
PASS All 46 assertions passed.
(Captured from a real run. In a terminal the ok column is a green ✓;
piped to a file or a CI log it degrades to words, which is what you see here.)
Thirty-four days of billing. No Stripe account. Sub-second.
The part live Stripe cannot test
Stripe guarantees at-least-once delivery and does not guarantee order.
Almost nobody tests against either. Redelivery you can provoke by hand — the
Dashboard has a resend button per event under Developers > Webhooks, and
stripe events resend <id> does it from the CLI. Both need a live account, a
network round trip, and an event that already happened, which makes them a
manual step rather than an assertion. Ordering you cannot provoke at all:
Stripe offers no way to ask for a step's events in a different sequence. A
fixture is a list you own, so replay does both — offline, seeded, in CI.
What this actually does, precisely: a fixture step is one position on the
clock — the events Stripe emitted at effectively the same moment. --shuffle
permutes the events within a step. --duplicate delivers every event a second
time, appended after the originals, the way a Stripe redelivery arrives. Both
are seeded, so a failing pass reproduces exactly.
What it does not do: move an event across a step boundary. Stripe's own
documentation offers "a subscription might be deleted before the corresponding
creation event arrives" as the extreme case, and this package cannot produce
that ordering — in the shipped fixture customer.subscription.created is at
step 0 and customer.subscription.deleted is at step 5, six clock positions
apart, and the orderer never crosses that gap. Reordering across a clock advance
is a real design question about which orderings are physically possible, and it
is not answered here.
Within a step is where the ordering bugs actually live. Step 2 of the shipped
fixture delivers invoice.created, two customer.subscription.updated and
invoice.payment_failed at one timestamp; nothing about Stripe's contract says
which lands first, and an application that only works in the recorded sequence
is broken:
$ php artisan billing:simulate trial-dunning-cancel-reactivate --duplicate --seed=7 --iterations=1
(the ordered pass runs first and prints its own timeline, then:)
Chaos same events, orders Stripe is entitled to use
ok pass 0: in order
FAIL pass 1: duplicated
[mail:replay@example.test] happened 9 time(s) in order and 18 time(s) duplicated
FAIL 1 ordering(s) changed the outcome. Reproduce with --seed=7.
That application ends the run with a perfectly correct subscription row. Its only defect is that it would have emailed the customer twice for each of nine failed payments — which is why comparing final state, the thing most billing test suites do, would call it idempotent. No mail left the building: replay records the attempt and refuses the delivery.
Installation
composer require impruthvi/cashier-dunning --dev
Requires PHP 8.3+, Laravel 11, 12 or 13, and laravel/cashier 16.
Laravel 11 is supported on PHP 8.3 and 8.4 only. Composer would let you install it on 8.5, but 11.x shipped before 8.5 existed and is not tested against it upstream, so it is not a combination this package advertises.
Getting started
Replay a shipped scenario immediately — nothing to configure, nothing to record:
php artisan billing:simulate trial-dunning-cancel-reactivate
The package needs to know which model the billing belongs to. In a service provider:
use Impruthvi\CashierDunning\CashierDunning; CashierDunning::createBillableUsing(fn () => User::factory()->create([ 'stripe_id' => 'cus_replay1', 'email' => 'replay@example.test', ]));
Pin the email too, not only the stripe_id. Side effects are keyed by recipient,
so a factory leaving the email to Faker gives every chaos pass a different
address and turns every comparison into a divergence that is really just a
different random string.
Before delivering any webhook, the command reads the customer id out of the
recording and checks that it resolves to exactly the record your factory
returned. Each way that can go wrong gets its own error — no stripe_id, the
wrong one, no matching row at all, or several rows sharing it — so you are told
what is actually broken instead of shown a red entitlement table.
Each replay rolls back its database writes, including the factory-created user, on success or failure. Repeated commands start from the same database state; transactions already open before replay remain open.
Isolation uses the connection of Cashier's configured customer model
(Cashier::useCustomerModel(...)). Your factory and billing writes must use that
same connection. Writes to other connections are outside this rollback guarantee.
Application code that commits the simulation's transaction is detected and fails
the run rather than silently persisting. With --record,
local verification writes roll back, but resources created in the Stripe test
account remain there.
A replay does not mail your customers
Replay posts a real invoice.payment_failed through your real webhook route, so
your real dunning listener runs — and a dunning listener's whole job is to email
the customer. On a machine with working SMTP credentials that email would be
sent, to whatever address your billable carries.
So the run is real right up to the last hop. Your listener runs, the mailable is built, the recipients are resolved, the attempt is recorded — and the delivery is refused. Every replay prints what your application tried to do:
$ php artisan billing:simulate trial-dunning-cancel-reactivate
(timeline omitted — see above)
Side effects what the application did
mail:replay@example.test ×9
9 outbound delivery(s) blocked. A replay never mails a real customer.
PASS All 46 assertions passed.
This is deliberately not Mail::fake(). A fake swaps the mailer out, so a
listener that formats a message wrongly never formats it at all — and the bug you
came to find stops being reachable. Instead, every configured mailer is pointed
at the array transport for the length of the run. Your listener runs, your
toMail() runs, the message is built and the recipients resolved; it simply has
nowhere to go. MessageSending still fires, so the ledger sees the attempt.
Notifications go the same way. Only channels with no mail transport behind them — Slack, SMS, and anything else that talks straight to a network — are refused outright, because there is no lower seam to stop them at.
The limit — queued work is observed, not blocked. JobQueued fires after
Laravel has already pushed the job, so there is nothing left to refuse. On a
database queue the row is inside the replay's transaction and disappears with
it; on Redis, SQS or Beanstalk the job survives the rollback and a worker will
run it afterwards, against data that no longer exists. If your dunning work is
queued, run replays with QUEUE_CONNECTION=sync — then the handler runs inline
and its mail is caught like any other.
If your application commits. A replay owns the transaction it opened. Code
that calls DB::commit() or DB::rollBack() on that connection takes it away,
and the rollback afterwards becomes a no-op that leaves real rows behind. The run
now checks and fails with a named error rather than reporting PASS over
persisted data.
Laravel work scheduled with ->afterCommit() cannot execute inside a replay
that never commits. The command detects those callbacks and exits with a named
failure instead of reporting that unseen side effects behaved correctly.
Entitlements
The interesting question is not what Stripe did — it is what your application let the customer do afterwards. That is a product decision no billing library can answer, so this package ships a contract and no implementation:
CashierDunning::resolveEntitlementsUsing(function (User $user) { $subscription = $user->subscription('default'); return [ 'teams' => $user->subscribed('default'), 'projects' => $user->subscribed('default') ? 10 : 0, ]; });
The resolver is called once after every webhook, not once per step, so
--explain can name the event that moved an entitlement:
teams: true -> false (at invoice.payment_failed)
That is the difference between "your users lost access" and "your users lost access three days before Stripe cancelled anything."
Registering nothing is fine. Subscription status and webhook handling are worth watching before any entitlements exist.
When a replay disagrees with the recording
$ php artisan billing:simulate downgrade-over-usage-limit
downgrade-over-usage-limit replayed with no Stripe account
FAIL +0d subscribed to the larger plan
invoice.created 200
invoice.finalized 200
invoice.paid 200
invoice.payment_succeeded 200
customer.subscription.created 200
feature recording application
api true true
projects 25 10
FAIL 1 step(s) did not match the recording, starting at step 0 (subscribed to the larger plan).
Every webhook returned 200. The subscription row is correct. The application simply grants 10 projects on a plan the recording says is worth 25 — a limit that drifted out of step with billing, which no amount of checking HTTP status codes would surface. The command exits non-zero.
Every declared feature is shown, not only the differing ones — a single red line reads as a glitch, while the same line among green ones reads as "everything else held, this one moved."
Commands
# replay a recorded lifecycle php artisan billing:simulate <scenario> # name the event behind every entitlement change php artisan billing:simulate <scenario> --explain # replay in orders Stripe is entitled to use php artisan billing:simulate <scenario> --shuffle --duplicate --seed=7 --iterations=4 # machine-readable report for CI php artisan billing:simulate <scenario> --json=build/billing.json # record against your own Stripe test account php artisan billing:simulate <scenario> --record # what can this installation do, offline php artisan billing:doctor
billing:doctor answers "will this work here" without running anything:
$ php artisan billing:doctor
cashier-dunning environment check
✓ Stripe key: not set. Fine for replay; recording needs a test key.
✓ Cashier billable model: App\Models\User
! Webhook secret: not set. Cashier skips signature verification, so replay cannot prove your signature handling works.
✓ Entitlement resolver: closure registered in code
✓ 2 fixture(s) valid in /app/vendor/impruthvi/cashier-dunning/fixtures
✓ Scenario [trial-dunning-cancel-reactivate]: recorded.
✓ Scenario [downgrade-over-usage-limit]: recorded.
Replay works with no Stripe account, key or network.
Record blocked: no key configured.
(The fixtures path is wherever Composer installed the package; the absolute path above is shortened.)
Recording your own
Recording drives a real Stripe test account through a test clock, then replays the result against your application before writing anything — a fixture that cannot be replayed is worse than no fixture, because it looks like coverage.
STRIPE_SECRET=sk_test_... php artisan billing:simulate trial-dunning-cancel-reactivate --record
Recording refuses to run against anything but a test key. The guard is an allowlist, not a blocklist: recording proceeds only when the key can be positively identified as a test key, so a prefix Stripe introduces next year cannot silently become permitted. Recording creates customers, subscriptions and invoices and advances a clock through months of billing — against a live key that means real charges to real cards, and there is no undo.
A recording that comes up short never becomes a fixture. It becomes a quarantined artifact holding what arrived, what did not, and the request ids Stripe support will ask for.
Scenarios
| Scenario | Story |
|---|---|
trial-dunning-cancel-reactivate |
A trial ends, the card fails, retries run out, the subscription is cancelled, the customer comes back |
downgrade-over-usage-limit |
A customer moves to a smaller plan mid-period and their limit drops below what they are already using |
cancel-at-period-end-then-canceled-early |
A customer schedules a cancellation, Stripe cancels early, and Cashier keeps reporting the subscription as active |
The first two fixtures were recorded against a dedicated synthetic Stripe test account with invented products, so there is nothing sensitive in them by construction. Field-level redaction runs on write against an allowlist that fails closed.
cancel-at-period-end-then-canceled-early is the exception: it is hand-authored
from Stripe's documented subscription object rather than recorded, because it
reproduces laravel/cashier-stripe#1791 and the point is to show the
defect without needing an account to see it. Its provenance says so.
How it works
Cashier's Stripe calls do not go through Laravel's HTTP client, so Http::fake()
cannot see them. Replay installs a transport into the Stripe SDK's own seam
(ApiRequestor::setHttpClient) and answers from the fixture. Webhooks are signed
with a real HMAC against an ephemeral secret and posted through your HTTP kernel,
so routing, middleware and Cashier's signature verification all run exactly as
they do in production.
While a replay is running, your real Stripe key is not in configuration to be read. Nothing can reach live Stripe by accident.
Two rules keep a replay honest:
- Nothing is invented. A Stripe call with no recorded response is an error naming the call and the step, never an empty body.
- Nothing is served out of time. Responses are scoped to the step that
recorded them, so a subscription that was
trialingat step 1 cannot answer for step 3 where it ispast_due.
And a run that asserted nothing cannot pass. A green billing test that proves nothing is worse than no test, because it stops anyone looking.
Testing
composer test
The suite runs entirely offline. The live recording tests skip themselves unless
STRIPE_SECRET is set, because GitHub withholds secrets from fork pull requests
and a suite that needed one would be red for exactly the contributors this
project wants.
Contributing
Fixtures are reviewed as diffs, so the format is deliberately stable: ids and timestamps are normalised to placeholders, key order is fixed, and encoding is pinned. A re-recording of the same journey should produce identical bytes.
Credits
License
The MIT License (MIT). Please see License File for more information.