Stripe + Laravel Subscriptions: 12 Gotchas I Hit and How I Fixed Them

By BuildVoyage Team September 2, 2025 3 min read Updated 1 day ago

The list I wish I had on day one

I’ve integrated Stripe with Laravel apps a dozen times. Every project broke in the same places. Here are the 12 “of course that happened” moments and the exact fixes.

1) Webhooks not retrying properly

Fix: Mark your webhook controller queueable and return a 200 quickly. Do the heavy work in jobs.

// app/Http/Controllers/StripeWebhookController.php
public function __invoke(Request $request)
{
    $payload = $request->all();
    dispatch(new ProcessStripeEvent($payload));
    return response('ok');
}

2) Idempotency keys on create checkout session

Fix: Generate one per intent and persist it. If a user double‑clicks, Stripe won’t create duplicates.

3) Trials that don’t end (Cashier)

Fix: Ensure you’re using trial_ends_at on the subscription and not only ends_at. Add a scheduled command that cancels stale trials daily.

4) Proration confusion on upgrades

Fix: Explicitly set proration_behavior for upgrades/downgrades. Document it in UI copy to avoid “why did I get charged $2.37?” emails.

5) Tax settings mismatched

Fix: Pick one source of truth. If using Stripe Tax, don’t duplicate logic in Laravel. Show tax estimate pre‑checkout to reduce surprise.

6) Customer portal returning to nowhere

Fix: Always pass a return_url and test it from mobile and desktop. Add a safe fallback route.

7) Seats vs members

Fix: If you bill for seats, enforce seat counts in your app. Sync Stripe quantity with team size and block invites beyond paid seats.

8) Inconsistent entitlements after plan change

Fix: Recompute entitlements on customer.subscription.updated. Store entitlements in one place (e.g., features JSON) and hydrate on login.

9) Deleted products referenced in code

Fix: Never hardcode Stripe IDs. Store them in config or the DB and reference by key. Add a feature flag for new price IDs.

10) Refunds without context

Fix: Add a refund_reason column. When issuing a refund, store the reason and link it to the support conversation.

11) Duplicate events processing

Fix: Use Stripe event id as a de‑dup key. Keep a table of processed IDs with a unique index.

12) Sandboxed tests that never hit the queue

Fix: In CI, run the queue worker for webhook tests or fake jobs explicitly. Don’t rely on synchronous behavior.


Pre‑launch checklist (billing)

  • Create, upgrade, downgrade, and cancel from a real test account
  • Dunning emails fire on invoice.payment_failed
  • Customer portal return_url works on mobile
  • Annual discount math visible and correct
  • Webhook retries and dead‑letter queue monitored

If cancellations are your current headache, grab these: Churn Is a Conversation: 7 Emails That Saved Real Accounts.

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

Related articles

Frequently asked questions

Cashier or raw Stripe SDK?
Use Cashier to move fast on standard subscriptions. Drop to the Stripe SDK for edge cases like metered billing, multi‑product entitlements, or custom tax logic.
Where do most failures happen?
Webhooks. Queue failures, missing idempotency keys, and silent 500s. Add retries, dead‑letter alerts, and a dashboard of last 100 events.
How do I test proration?
In test mode, switch from monthly to annual and vice‑versa on a live subscription. Inspect the upcoming invoice via API to ensure the credit line items look right.
About the author

BuildVoyage Team writes about calm, steady growth for indie products. BuildVoyage highlights real products, their stacks, and milestones to help makers learn from each other.