Stripe is the default choice for payments in Laravel startups - and for good reason. But "integrate Stripe" covers a lot of ground. One-off charges, subscriptions, refunds, payment method management, and the webhook events that tie it all together are different problems. I've integrated Stripe into a production multi-tenant Laravel SaaS using Laravel Cashier and spatie/laravel-stripe-webhooks. Here's what I'd tell you before you start.
Two Layers: Payments vs Subscriptions
The first thing to get clear is which kind of Stripe integration you're building. They're different enough to warrant different tools.
One-off payments - a single charge, a product purchase, a top-up - use Stripe's Payment Intents API directly. You create a PaymentIntent on your server, pass the client secret to the front-end, Stripe's Elements or Checkout handles card collection and SCA, and Stripe confirms the payment. Laravel Cashier can handle this, but it's not what it's designed for. For simple one-off payments, the Stripe PHP SDK on its own is often cleaner.
Subscriptions and recurring billing - plans, trials, upgrades, downgrades, proration, invoice management - are exactly what Laravel Cashier is built for. If you're building anything with recurring revenue, reach for Cashier.
Laravel Cashier: What It Does and When to Use It
Cashier is Laravel's official Stripe billing library. Add the Billable trait to your User (or whatever model represents your billing entity), run the migrations, and you get a clean API for subscriptions, payment methods, invoices, and more.
$user->newSubscription('default', 'price_monthly')->create($paymentMethod);
$user->subscription('default')->cancel();
$user->subscription('default')->swap('price_annual'); Cashier stores Stripe customer IDs, subscription IDs, and billing state in your database, and it keeps them in sync via webhooks. It handles the fiddly edge cases: trial periods, grace periods when a subscription is cancelled, proration on plan changes. You don't want to implement that yourself.
When not to use Cashier: if your billing entity isn't a simple User row (multi-tenant apps where billing belongs to a Team or Organisation rather than an individual), you'll need to configure Cashier's model carefully - or accept that you'll fight it. In a multi-tenant SaaS I worked on, the billing entity was a tenant Organisation, not the User. Cashier supported this with configuration, but it needed care to make sure the right model had the Billable trait and that Stripe customer IDs weren't accidentally shared across tenants. Worth thinking through before you start.
Laravel Spark: Cashier Plus a UI
If you want more out of the box than Cashier gives you, there's Laravel Spark. Built on top of Cashier, Spark adds a billing portal: subscription plan selection, plan switching, payment method management, invoice history, all rendered as drop-in pages you can mount inside your app. It also handles per-seat and team billing, which Cashier alone doesn't. Spark is a paid Laravel product with a one-off licence per project.
For a SaaS where billing UX is a meaningful part of the product, Spark saves you weeks of building the same screens everyone needs. For an internal app or one where billing is a quiet background concern, Cashier on its own is enough. I've used both depending on the project.
Webhooks: Don't Roll Your Own
Stripe webhooks are how Stripe tells your app what happened: a payment succeeded, a subscription renewed, a card declined. Your app needs to listen and react. Rolling your own webhook handler is not hard to start - but getting it right is harder than it looks. Signature verification, idempotency, event type routing, error handling, retries: each one is a trap if you skip it.
spatie/laravel-stripe-webhooks handles all of that. Install it, configure your webhook secret, and map Stripe event types to Laravel jobs or listeners. The package verifies the Stripe signature on every request (rejecting anything that doesn't match), stores the raw payload in a database table so you can inspect or replay events, and dispatches to your handler jobs.
// config/stripe-webhooks.php
'handlers' => [
'customer.subscription.updated' => \App\Jobs\HandleSubscriptionUpdated::class,
'invoice.payment_succeeded' => \App\Jobs\HandleInvoicePaymentSucceeded::class,
'invoice.payment_failed' => \App\Jobs\HandleInvoicePaymentFailed::class,
], Each handler is a queued job. That's the right design: Stripe expects your webhook endpoint to respond quickly (within a few seconds), so you acknowledge receipt and do the real work in the background. If your handler does heavy lifting synchronously, you'll start getting Stripe retry storms when your server is under load.
Queues and Idempotency
Because webhook handlers are queued jobs, you get retries for free - if a job fails, Laravel retries it. That's a feature, not a risk, as long as your handlers are idempotent: processing the same event twice produces the same result as processing it once. For most billing events this is straightforward. Updating a subscription's status based on the event data is idempotent by nature - setting a value to what it already is does nothing. Emailing a receipt is not idempotent by default, so guard it: check whether you've already sent it, or use Stripe's event ID as a deduplication key.
The Spatie package stores each event in the database before dispatching the job. That gives you a record you can query: "did we process this event?" is a database lookup. I've written more on why queues are the right model for this kind of background work - the same reasoning that applies to emails and API calls applies here.
Common Pitfalls
Test mode vs live mode key confusion
Stripe has separate API keys for test and live mode - and it's easy to accidentally mix them. A test mode key will not charge real cards; a live mode key will. Keep both in your .env and make sure your staging environment never has access to live keys. Use environment-specific config and double-check when deploying. I've seen staging accidentally configured with live keys; it's a bad day.
Webhook signature verification
Always verify the Stripe-Signature header. Stripe signs every webhook with your endpoint's signing secret, and you must verify that signature before trusting the payload. The Spatie package does this automatically - but if you ever write a custom endpoint or handle events outside the package, don't skip this step. An unverified webhook endpoint is an open door for spoofed events.
Race conditions between API calls and webhooks
Stripe sometimes fires a webhook before your API response has returned. Classic example: you call $user->newSubscription(...)->create(), Stripe creates the subscription, fires customer.subscription.created, your webhook handler runs - all before your own code has finished writing to the database. Your handler looks up the subscription by Stripe ID and finds nothing.
The fix is to not rely on your local database being in a consistent state when a webhook fires. Pull the subscription state from the Stripe event payload itself, not from a local lookup that might not exist yet. Alternatively, make your webhook handlers tolerant of a short delay - a brief retry with backoff handles most cases.
Customer ID vs subscription ID
Stripe gives every billable entity a customer ID (cus_xxx) and every subscription a subscription ID (sub_xxx). They're different things. Payment methods and invoices live under the customer; subscriptions have their own ID. Most webhook events include both. Store both in your database, index them, and use the right one to look up the right thing. Confusing them leads to subtle bugs that are hard to reproduce in test mode.
Cashier's webhook handling vs Spatie's
Cashier ships with its own webhook controller that handles the Stripe events Cashier cares about (subscription state sync, etc.). If you're using both Cashier and the Spatie package, you need to make sure events aren't handled twice - or that your Spatie handlers don't conflict with Cashier's. The simplest approach: let Cashier handle its own events via its controller, and use the Spatie package for your application-level events (sending receipts, updating feature flags, provisioning resources). Keep the responsibilities separate.
Local Development and the Stripe CLI
You can't receive webhooks on localhost without a tunnel. The Stripe CLI solves this: stripe listen --forward-to localhost:8000/stripe/webhooks forwards Stripe's test events to your local app and prints the signing secret to use in your .env. You can also trigger specific events manually: stripe trigger invoice.payment_succeeded. This is the right way to test your handlers without faking data. It's much faster than writing tests that mock Stripe responses from scratch.
For full test coverage, combine the Stripe CLI for integration testing with unit tests on your handler jobs. The handler logic - what you do with the event - should be testable in isolation with a fake payload. See Laravel best practices for startups for how I think about test coverage generally.
API Design Around Billing
One thing that trips up Laravel Stripe integrations is letting billing logic leak across the codebase. Cashier calls scattered through controllers, webhook handlers updating models directly, feature flags checked by querying the Stripe subscription object at runtime. Keep billing behind a service layer: a BillingService or SubscriptionService that your controllers and jobs call. The internals can change (Cashier version bump, plan restructure) without hunting through every controller. It also makes testing easier - you stub the service, not Cashier itself. For more on keeping APIs and integrations behind clean boundaries, see API design for startups.
What I'd Do Again
Cashier plus spatie/laravel-stripe-webhooks is a solid combination. Cashier handles the subscription lifecycle; the Spatie package handles event routing, signature verification, and storage. You write the business logic in focused, testable job classes. The rough edges are in the edge cases - multi-tenant billing models, race conditions, key hygiene - not in the libraries themselves.
If you're building Laravel payment or subscription features and want a second opinion on your integration, or you need someone to take it from zero to production, I'm a freelance Laravel developer based in Wiltshire, near Bath, working with startups across Bristol, Bath, and the UK. Reach out and let's talk through what you're building.