All posts

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.

MU

Muhammad Usman

June 10, 2026 · 2 min read

Animated diagram: Stripe Subscriptions in Laravel: What the Docs Skip

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 confirming

Test 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.

More posts