Where Stripe integrations usually break

Most Stripe + Laravel subscription integrations break in the same handful of places. Here are 12 common ones and how to fix them. The examples assume Laravel Cashier; the ideas apply if you call the Stripe SDK directly.

1) Slow or unverified webhook handlers

Stripe expects a quick 2xx response and retries deliveries that fail or time out. If your handler does heavy work inline, it can time out and be retried while the first attempt is still running.

Fix: Verify the signature, hand the work to a queued job, and return 200 quickly.

// app/Http/Controllers/StripeWebhookController.php
use App\Jobs\ProcessStripeEvent;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;

public function __invoke(Request $request): Response
{
    try {
        $event = Webhook::constructEvent(
            $request->getContent(),
            (string) $request->header('Stripe-Signature'),
            config('cashier.webhook.secret'),
        );
    } catch (SignatureVerificationException|\UnexpectedValueException) {
        return response('Invalid payload', 400);
    }

    ProcessStripeEvent::dispatch($event->id, $event->type, $event->toArray());

    return response('ok');
}

If you use Cashier’s built‑in webhook controller instead, set STRIPE_WEBHOOK_SECRET so its signature‑verification middleware is active, and listen for Cashier’s WebhookReceived event for your own handling. Either way, exclude the webhook route from CSRF verification.

2) Duplicate Checkout Sessions on double‑click

Fix: Pass an idempotency key when creating the session (the Stripe PHP SDK accepts an idempotency_key request option), derived from something stable such as the user, the price and a short‑lived attempt ID you persist. A retried request with the same key returns the original result instead of creating a second one. Disabling the button after the first click helps too.

3) Trials that don’t behave as expected

Cashier supports two kinds of trial, and mixing them up is a common source of bugs:

  • Trials on a Stripe subscription (trialDays() when creating it). Stripe converts the subscription to paid automatically when the trial ends. If you start trials without collecting a payment method, configure what happens at the end (Stripe’s trial_settings.end_behavior.missing_payment_method can cancel or pause the subscription).
  • Generic trials (trial_ends_at on your billable model, no Stripe subscription yet). Stripe knows nothing about these, so your app must gate access with onGenericTrial() and prompt for payment before the date passes.

Fix: Pick one approach per product, and test the moment a trial ends, not just the start.

4) Proration confusion on upgrades

Fix: Decide your proration behavior explicitly instead of relying on defaults. Cashier offers swap(), swapAndInvoice() and noProrate(); on the raw API, set proration_behavior. Explain it in your UI copy so customers aren’t surprised by a small, odd‑looking charge after an upgrade.

5) Tax settings mismatched

Fix: Pick one source of truth. If you use Stripe Tax, don’t duplicate tax logic in Laravel. Show a tax estimate before checkout to reduce surprises.

6) Customer portal returning to nowhere

Fix: Always pass a return URL (Cashier’s redirectToBillingPortal() takes one) and test it on mobile and desktop.

7) Seats vs members

Fix: If you bill for seats, enforce seat counts in your app. Sync the Stripe subscription quantity with team size (Cashier’s updateQuantity()) and block invites beyond paid seats.

8) Inconsistent entitlements after a plan change

Fix: Recompute entitlements when you process customer.subscription.updated and customer.subscription.deleted. Store them in one place (for example, a features JSON column or a plan‑to‑features map in config) and check that on each request, rather than scattering plan checks through the codebase.

9) Hardcoded price IDs

Stripe prices can’t be edited once used; you archive them and create new ones, and test and live mode have different IDs.

Fix: Never hardcode Stripe IDs in application code. Keep them in config (backed by environment variables) or the database, and reference them by a key such as pro_monthly.

10) Refunds without context

Fix: When issuing a refund, record why (Stripe accepts a reason and metadata on refunds) and link it to the support conversation, so you can spot patterns later.

11) Processing the same event twice

Stripe can deliver the same event more than once.

Fix: Use the Stripe event id as a de‑duplication key. Keep a table of processed event IDs with a unique index and skip events you’ve already handled.

12) Tests that never hit the queue

Fix: In tests, either run queued jobs synchronously on purpose and assert their effects, or use Queue::fake() and assert the job was dispatched, then test the job separately. Don’t assume the behavior you see locally with the sync driver matches production.


Pre‑launch checklist (billing)

  • Create, upgrade, downgrade, and cancel from a test account
  • Walk a subscription through trial end and renewal with a test clock
  • Dunning emails fire on invoice.payment_failed
  • Customer portal return URL works on mobile
  • Annual discount math visible and correct
  • Webhook signature verification on, and failed jobs monitored

If cancellations are your current headache, see Churn Is a Conversation: 7 Emails to Send Before Customers Leave.

Building the pricing page next? Pair this with SaaS Pricing Page: Wireframes, Copy, Laravel + Tailwind.