Oleksii Siniaiev
Post page navigation
Articles 10 min read June 21, 2023

Laravel Subscriptions with Cashier and Stripe: What Breaks in Production

A practical guide to managing Laravel subscriptions with Cashier and Stripe, including setup, model configuration, webhook thinking, and clean billing architecture.

On this page

TL;DR

  • Cashier makes the happy path three lines of code. Production billing is everything around that path: webhooks, SCA, failed payments, trials ending, and plan swaps.
  • Stripe is the source of truth. Your database is a read model that webhooks keep in sync — so provision access in the webhook handler, never in the controller that called create().
  • Webhooks arrive at-least-once and out of order. Any side effect you trigger from one (granting credits, sending email) has to be idempotent or it will fire twice.
  • Gate access on subscription state (subscribed(), onGracePeriod()), not on a boolean you set yourself. The state machine is the product.

The first Laravel subscription feature I shipped worked perfectly in the demo and broke in the first week of real customers. The demo used a US test card, went straight to active, and granted access immediately. Then a customer in Spain signed up, their bank triggered 3D Secure, the subscription landed in incomplete, and my code had already flipped a is_premium flag to true in the controller. They had access they had not paid for. A week later a renewal failed, Stripe moved the subscription to past_due, and nothing in my app noticed because I was reading my own flag instead of the subscription state.

None of that is a Cashier problem. Cashier is genuinely good. The problem is that every tutorial — including the one this article used to be — stops at newSubscription()->create() and calls it a comprehensive guide. The three lines are the easy 10%. This is the other 90%: the webhook flow, the subscription states you actually have to handle, and how to structure the code so a pricing change does not ripple through twenty controllers.

What Cashier actually gives you (and what it does not)

Cashier is a wrapper around the Stripe API that maps Stripe’s billing objects onto Eloquent models. It gives you a Billable trait, three database tables, expressive methods like subscribed() and swap(), and — this is the part people undervalue — a webhook controller that keeps your local tables in sync with Stripe automatically.

What it does not give you: a decision about when a failed payment should revoke access, a UI for SCA confirmation, idempotent business logic, or any opinion about how to model entitlements. Those are yours. Cashier handles the billing plumbing; the product rules around billing are the part you still have to design.

Minimal production setup

Install the package and publish its migrations. Newer Cashier versions use vendor:publish rather than the old cashier:table command:

Bash
composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate

That creates the subscriptions and subscription_items tables and adds Stripe columns to your users table. Then the environment — and note the third variable, which most setups forget until webhooks silently fail:

Code
STRIPE_KEY=pk_live_...
STRIPE_SECRET=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

Add the Billable trait to whichever model owns the billing relationship. It is usually User, but on a B2B product it is often a Team or Account — decide this early, because moving it later is painful:

Code
use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;
}

Creating a subscription — and why the happy path lies

The expressive call everyone shows you:

Code
$user
    ->newSubscription('default', config('billing.prices.premium_monthly'))
    ->create($paymentMethodId);

Two things are already different from the tutorial version. The price comes from config, not a hardcoded string, because price IDs differ between your Stripe test and live modes and will change again when you reprice. And the result of create() is not guaranteed to be an active subscription.

Strong Customer Authentication breaks the happy path

European cards (and increasingly others) require 3D Secure confirmation. When that happens, create() succeeds but the subscription status is incomplete, not active. The customer still has to confirm the payment. If you grant access right after create() returns, you grant access to people who have not actually paid.

This is exactly why you do not provision access here. The controller’s job is to start the subscription and, if the payment needs confirmation, hand the client a payment intent to complete. Access gets granted later, when Stripe confirms the payment and tells you so through a webhook.

Webhooks are the real source of truth

Cashier ships a webhook controller. Point a Stripe webhook endpoint at /stripe/webhook, set STRIPE_WEBHOOK_SECRET, and exclude that route from CSRF protection. Out of the box, Cashier listens to the subscription and invoice events and keeps your local tables correct: a renewal, a cancellation, a failed payment, or an SCA confirmation all update the row in your database without you writing a line.

What Cashier cannot do is run your side effects — provisioning a workspace, granting API credits, sending a welcome email. You add those by listening to Cashier’s WebhookReceived event:

Code
use Laravel\Cashier\Events\WebhookReceived;

class HandleStripeWebhook
{
    public function handle(WebhookReceived $event): void
    {
        if ($event->payload['type'] === 'invoice.payment_succeeded') {
            $this->provisionAccess($event->payload['data']['object']);
        }
    }
}

Two rules make this safe, and skipping either one is the most common way subscription billing goes wrong in production.

Webhooks are at-least-once and unordered

Stripe will occasionally deliver the same event twice, and it does not guarantee order. You can receive subscription.updated before the subscription.created it logically follows. Every handler has to be safe to run twice and safe to run out of sequence.

Make side effects idempotent. Before granting 500 API credits, check whether this invoice has already been processed — key it on the Stripe invoice or event ID, store that you handled it, and no-op on the second delivery. A welcome email guarded by a welcomed_at timestamp will not go out twice. The cost of forgetting this is double-charged credits and duplicate emails, and you will only find out from an annoyed customer.

The subscription states you actually have to handle

“Subscribed or not” is two states. Real Stripe billing has at least six that change what your product should do. This is the table I keep next to me when wiring access control:

StateWhat it meansCashier checkGrant access?
trialingIn a trial period, not yet billedonTrial()Yes
activePaid and currentsubscribed()Yes
incompleteFirst payment needs SCA confirmationhasIncompletePayment()No
past_dueA renewal failed; Stripe is retryingsubscription()->past_due()Your call (see below)
canceled, on grace periodCanceled but paid through period endonGracePeriod()Yes, until the period ends
canceled, endedPeriod is over, access lapsedsubscribed() is falseNo

The past_due row is a product decision, not a technical one. Stripe runs its dunning retries over days. Do you cut off access the moment a renewal fails, or keep the customer in for the retry window and only revoke when Stripe gives up and moves to canceled? Cutting off immediately reduces revenue leakage but punishes a customer whose card just expired. Most SaaS products keep access through the retry window and show a “your payment failed, update your card” banner. There is no right answer; there is only a decision you should make deliberately instead of by accident.

Trials, swaps, and cancellations

Trials come in two shapes. A trial with a card up front uses trialDays() on creation. A trial without a card — let people in before asking for payment — uses a trial timestamp on the model and no Stripe subscription yet:

Code
// Card required up front
$user->newSubscription('default', $priceId)
    ->trialDays(14)
    ->create($paymentMethodId);

// No card up front (generic trial)
$user->trial_ends_at = now()->addDays(14);
$user->save();

The cardless trial is better for conversion but means you have to handle the moment the trial ends with no payment method — gate the app and prompt for a card before trial_ends_at passes.

Plan changes are a single call, but proration is the detail that generates support tickets:

Code
$user->subscription('default')->swap($newPriceId);            // prorates by default
$user->subscription('default')->noProrate()->swap($newPriceId); // no proration

Cancellation is where teams quietly lose customer goodwill. cancel() does not end the subscription immediately — it cancels at period end, so the customer keeps the access they already paid for, which is what onGracePeriod() reports. cancelNow() revokes immediately and refunds nothing. Reach for cancel() unless you have a specific reason not to:

Code
$user->subscription('default')->cancel();    // access until period end
$user->subscription('default')->resume();    // changed their mind during grace
$user->subscription('default')->cancelNow(); // immediate, no grace

Put the billing logic behind one boundary

The mistake that makes subscription code unmaintainable is scattering $user->subscribed('default') checks across controllers, Blade views, and jobs. The day you add an annual plan or a second product, you are hunting for every one of them.

Keep one method that answers the product question — “can this user use this feature?” — and let it read the subscription state internally. Everything else calls that method and knows nothing about Stripe:

Code
public function canAccessPremium(): bool
{
    return $this->subscribed('default')
        || $this->onTrial()
        || $this->subscription('default')?->onGracePeriod();
}

This is the same dependency-inversion idea applied to billing: the application depends on a stable question, not on the volatile details of how Stripe answers it. If you want the reasoning behind that boundary, I wrote it up in dependency injection and dependency inversion in Laravel. Keeping Stripe behind a focused service is also what makes the failure modes in web application security tractable: webhook signature verification and access checks live in one place you can audit, not twenty.

Mistakes I see in real Laravel subscription code

Provisioning in the controller

Granting access right after create() ignores SCA and request failures. Provision in the webhook handler instead.

Non-idempotent webhook handlers

At-least-once delivery means duplicates. Key side effects on the Stripe event or invoice ID and no-op the second time.

A homegrown is_premium flag

It drifts from Stripe the moment a payment fails silently. Read subscription state; do not mirror it by hand.

Skipping the webhook secret

Without STRIPE_WEBHOOK_SECRET, anyone can POST fake events to your endpoint. Verify every signature.

Hardcoded price IDs

They differ between test and live mode and change when you reprice. Put them in config, not in code.

Treating cancel as immediate

Using cancelNow() by default throws away access the customer already paid for. Default to cancel().

Testing subscription flows without waiting a month

You cannot validate renewals and trial expiry by waiting real time. Stripe test clocks let you create a customer attached to a simulated clock and fast-forward it past a renewal or the end of a trial, then assert your webhooks fired and your access logic responded. Combine that with Stripe test mode and Cashier’s own test helpers, and you can cover the cases that actually break — failed renewal, SCA confirmation, trial ending without a card — in your CI pipeline instead of in production.

The flows worth a test each: a successful subscribe, a renewal that fails and moves to past_due, a cancellation that keeps grace-period access, and a webhook delivered twice that must not double-provision. Those four cover most of the incidents I have seen.

FAQ

Where should I grant access — in the controller or the webhook?
The webhook. The controller starts the subscription, but the payment may still need SCA confirmation, and the HTTP request can fail after create() succeeds. Granting access on invoice.payment_succeeded in a webhook handler is the only point where you know the customer actually paid.
How do I stop Stripe webhooks from running my logic twice?
Make the handler idempotent. Store the Stripe event or invoice ID once you have processed it, and check for it before running side effects again. Stripe delivers at-least-once, so duplicates are expected, not exceptional.
What is the difference between cancel() and cancelNow() in Cashier?
cancel() cancels at the end of the current billing period, so the customer keeps access they paid for — Cashier reports this as onGracePeriod(). cancelNow() ends the subscription immediately with no grace period. Default to cancel().
Why is my subscription stuck in “incomplete”?
The first payment requires Strong Customer Authentication (3D Secure) and the customer has not confirmed it. create() returned, but the subscription is not active. Surface the payment-intent confirmation to the customer; access should not be granted until the payment succeeds.
Should I cut off access the moment a payment fails?
That is a product decision. Stripe retries failed payments over several days (past_due). Most SaaS products keep access through the retry window and show a card-update prompt, only revoking when Stripe gives up. Decide deliberately and encode it in one place.

Updated: June 17, 2026

Share this article

LinkedIn X Email

Get in touch

Working on something similar? Let's talk.

I am always open to discussing architecture, Laravel, WordPress, performance, and practical implementation problems.

Send a message See selected work

Explore more

Articles

June 14, 2026

Part 3. A month with an AI journal: finding patterns in sleep, stress, and workouts

How to analyze an AI journal after the first month: transcription fixes, honest reflection…
Articles

June 14, 2026

Part 2. Hermes Agent + DeepSeek on Ubuntu: a complete Telegram AI journal setup

A step-by-step setup for Hermes Agent and DeepSeek on Ubuntu: a locked-down Telegram bot,…
Articles

June 14, 2026

Part 1. How I turned an old gaming laptop into an AI wellbeing journal

How an old Xiaomi Mi Gaming Laptop became a home AI server: Hermes Agent,…