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:
composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrateThat 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:
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:
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:
$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:
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:
| State | What it means | Cashier check | Grant access? |
|---|---|---|---|
trialing | In a trial period, not yet billed | onTrial() | Yes |
active | Paid and current | subscribed() | Yes |
incomplete | First payment needs SCA confirmation | hasIncompletePayment() | No |
past_due | A renewal failed; Stripe is retrying | subscription()->past_due() | Your call (see below) |
| canceled, on grace period | Canceled but paid through period end | onGracePeriod() | Yes, until the period ends |
| canceled, ended | Period is over, access lapsed | subscribed() is false | No |
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:
// 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:
$user->subscription('default')->swap($newPriceId); // prorates by default
$user->subscription('default')->noProrate()->swap($newPriceId); // no prorationCancellation 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:
$user->subscription('default')->cancel(); // access until period end
$user->subscription('default')->resume(); // changed their mind during grace
$user->subscription('default')->cancelNow(); // immediate, no gracePut 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:
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 oninvoice.payment_succeededin 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 asonGracePeriod().cancelNow()ends the subscription immediately with no grace period. Default tocancel().- 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.
Related articles
- Dependency injection and dependency inversion in Laravel covers the boundary pattern that keeps Stripe-specific code out of your controllers and views.
- The importance of security in web development covers webhook signature verification and the access-control checks billing depends on.




