Stripe Subscriptions in Laravel: What the Docs Skip
Production Stripe billing in Laravel: verified idempotent webhooks, dunning states, proration previews, and a go-live checklist, with code.
Muhammad Usman
June 10, 2026 · 2 min read
The happy-path Stripe integration takes a day. Production billing is everything else. These are the parts that actually matter, from the billing systems I have shipped and rescued.
Webhooks are the source of truth
Never mark a payment successful because the user reached your success page. Users close tabs, banks delay, 3D Secure interrupts. Subscription state changes only from verified webhook events, and every handler must be idempotent because Stripe resends events:
// routes/web.php
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle']);
// The handler: verify signature, dedupe, then act
public function handle(Request $request)
{
$event = \Stripe\Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret')
);
// Idempotency: skip events we already processed
if (ProcessedEvent::where('event_id', $event->id)->exists()) {
return response()->json(['status' => 'duplicate']);
}
ProcessedEvent::create(['event_id' => $event->id]);
match ($event->type) {
'invoice.paid' => $this->markActive($event),
'invoice.payment_failed' => $this->markPastDue($event),
'customer.subscription.deleted' => $this->downgrade($event),
default => null,
};
return response()->json(['status' => 'ok']);
}Failed payments are a feature, not an edge case
- Enable smart retries and dunning emails in the Stripe dashboard
- Track past_due as its own state in your subscriptions table, it is not canceled
- Keep access during the dunning window, downgrade only after the final retry fails
- Send customers Stripe’s hosted payment-update link instead of building your own page
Preview proration before upgrades
Mid-cycle plan changes prorate by default and surprise everyone. Show the exact charge before the customer confirms:
// What will this upgrade cost right now?
$preview = \Stripe\Invoice::upcoming([
'customer' => $customer->stripe_id,
'subscription' => $subscription->stripe_id,
'subscription_items' => [[
'id' => $itemId,
'price' => $newPriceId,
]],
]);
// show $preview->amount_due to the customer before confirmingTest with test clocks before go-live
Stripe test clocks fast-forward time: simulate renewals, trial endings, and failed payments in minutes instead of waiting a month. If you have only tested successful payments, you have not tested billing.
Go-live checklist
- Webhook signature verified and endpoint answering within seconds
- Idempotency table in place, duplicate events are no-ops
- States modeled: trialing, active, past_due, canceled
- Dunning window configured with customer emails
- Proration previewed on every plan change
Frequently asked questions
Laravel Cashier or the Stripe API directly?+
Cashier for standard SaaS subscriptions, it saves real boilerplate. Combine it with direct API calls for marketplaces, custom proration, or Connect payouts.
What happens when a subscription payment fails?+
Stripe retries on a smart schedule and fires webhooks at each step. Keep access during dunning, downgrade only after the final failure.
How do I test renewals without waiting a month?+
Stripe test clocks. Create a clock, attach a test customer, and advance time to trigger renewals, trials, and failures instantly.