Subscription Billing
Recurring billing that chases a failed payment instead of losing the customer.
Built from scratch to demonstrate cashier plus dunning recovery

What this solves
Most subscription implementations handle the happy path well. Someone subscribes, the card charges every month, everyone is content.
The money is lost in the other path. Cards expire, banks decline, limits are hit. The customer never finds out, because a renewal failing is silent by design: nobody is sitting at a checkout screen watching it happen. They notice weeks later when something stops working, and by then they have moved on.
That is involuntary churn, and it is customers leaving by accident rather than by choice. I built this to demonstrate a subscription system where the failed renewal is treated as the important event rather than the edge case.
How I approached it
- Laravel Cashier handles the subscription lifecycle: creation, upgrades, downgrades, cancellations, grace periods and trials. These are proven abstractions and rewriting them is a way to introduce bugs, not to add value.
- Checkout returns a Stripe Checkout session, and plan changes go through the Stripe Billing Portal. That is a deliberate decision: building your own plan-change UI means owning proration maths, SCA re-authentication and regional compliance, which is a large surface to maintain for no differentiation.
- Failed renewals are the actual feature. An invoice.payment_failed event queues a job that writes a recovery email tailored to that customer's plan, using a structured output schema, with a template fallback so the path never breaks when the model is unavailable.
- Plan gating is enforced on the API. A pro-only endpoint returns 403 regardless of what the frontend believes, because a feature flag in the browser is a suggestion rather than a boundary.
- Authentication uses HttpOnly cookies set server side through Next.js route handlers, so the session token is never readable by client JavaScript and cannot be lifted by an XSS bug.
Recovering a payment that failed without anyone noticing it failed.
Why the obvious solution fails
The obvious answer is to let Stripe retry on its schedule and move on. That recovers some payments and quietly loses the rest, because a retry only helps when the problem is temporary. An expired card is not temporary: it will fail every time until a human updates it, and nothing in the retry cycle asks them to. The next obvious answer, a generic 'your payment failed' email, performs badly for the same reason all generic billing email does. It reads like a system notice, arrives with no context about what the customer actually loses, and gets ignored.
What I did instead
- The failure is treated as an event worth acting on. invoice.payment_failed dispatches a queued job rather than being logged and forgotten.
- The recovery email is generated per customer against a structured output schema, referencing their plan rather than being one template sent to everybody.
- A static template backs it up, so if the model is unavailable the customer still gets a message. A dunning path that depends on an API being up is not a dunning path.
- The fix itself is handed to the Stripe Billing Portal, so updating a card happens somewhere already built to handle SCA and regional rules, rather than in a form I would have to keep compliant.
How the data flows
A Laravel 13 API with Cashier 16 and Sanctum 4 owns subscription state, with a Next.js 14 frontend. Stripe hosts checkout and the billing portal. Webhook work runs on a database queue.
- A user subscribes through a Stripe Checkout session created by the API.
- Stripe sends webhooks to a single endpoint, which records the event id under a unique constraint.
- A repeat delivery of an event already seen returns 200 without running any handler.
- A first delivery dispatches ProcessStripeWebhook, keeping the response under a second and inside Stripe's timeout window.
- Cashier keeps local subscription state in step with Stripe for upgrades, downgrades, cancellations and grace periods.
- On invoice.payment_failed a queued job drafts a recovery email tailored to the customer's plan, with a template fallback.
- The customer updates their card in the Stripe Billing Portal, and the subscription resumes.
What I'd do differently
- Recovery emails are logged rather than sent. Production needs a real delivery path plus suppression rules, because the fastest way to turn a recoverable customer into a lost one is to email them every day.
- Dunning needs a defined cadence and a stopping point. A schedule of a few spaced attempts and then a downgrade is a business decision that has to be made explicitly rather than left to whatever the code happens to do.
- The demo runs on SQLite. Production means MySQL or Postgres, particularly since subscription state is written from webhooks arriving concurrently.
- Nothing measures whether the AI written emails actually recover more payments than the template. That comparison is the only thing that justifies the feature, and it needs holding one against the other and counting.

Want something like this built?
I build payment systems and AI features inside Laravel apps. Tell me what you need and I will tell you how I would approach it.
Get in touch