Billing is the one part of a SaaS product where small bugs turn directly into lost money or angry customers. Charge someone twice, or keep a cancelled account active for months, and it hurts. I've built Stripe billing into several Laravel SaaS products, including multi-tenant platforms like TrackReward, and these are the patterns I now use every time.
Treat webhooks as the source of truth
It's tempting to update a customer's subscription right after checkout succeeds in the browser. But the browser isn't reliable: people close tabs, lose connection, use a back button. Stripe's webhooks are the reliable signal. Your app should listen for events like:
checkout.session.completedinvoice.paidinvoice.payment_failedcustomer.subscription.updatedcustomer.subscription.deleted
and update its own records from them.
1. Verify the signature
Every webhook request must be checked against your endpoint's signing secret before you trust it. Laravel Cashier does this for you. If you handle webhooks yourself, use Stripe's library to verify. Never skip this.
2. Store the event, then respond quickly
Stripe expects a fast 200 response. I save the raw event in a stripe_events table, dispatch a queued job, and return immediately. The heavy work happens in the queue. If something fails, the event is still stored and can be retried.
3. Make every handler idempotent
Stripe can deliver the same event more than once. That's documented, normal behaviour. If handling an event twice would send two welcome emails or add credits twice, you have a bug waiting to happen.
The fix is simple: use the event ID as a unique key. Before processing, check whether you've already handled it. Wrap the processing in a database transaction.
4. Don't rely on event order
Events can arrive out of order. When it matters, I re-fetch the latest object from the Stripe API inside the job instead of trusting the event payload. That way the app always ends up in the correct state, whatever order things arrived in.
5. Make it tenant-aware
In multi-tenant apps, each event must map to the right tenant, usually through the Stripe customer ID stored on the tenant record. The queued job then initializes the correct tenant before touching any data. Getting this wrong is how one customer ends up seeing another's plan.
6. Handle failures like a human would
invoice.payment_failed shouldn't instantly lock someone out. I give a grace period, send a friendly email with a link to update the card, and only restrict the account after Stripe's retries are exhausted.
7. Test it properly
- Use the Stripe CLI to forward events to your local app.
- Trigger the main events on purpose: success, failure, cancellation, plan change.
- Replay the same event twice and confirm nothing doubles.
- Write automated tests with saved event payloads.
The payoff
When billing is built this way, it becomes the quiet part of your product. Subscriptions match reality, support tickets about charges drop, and you can trust your revenue numbers.
Billing is a core part of my SaaS and web app development service. If your billing is causing support tickets, let's look at it together.