laravel/cashier
Laravel Cashier offers an expressive, fluent interface to Stripe's subscription billing, handling nearly all the boilerplate for you—including coupons, subscription swapping, quantities, cancellation grace periods, and invoice PDFs.
Install the package and publish its migration/config scaffolding:
composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan vendor:publish --tag="cashier-config" # optional but do it once
php artisan migrate
Set the three credentials Cashier reads, then restart the queue/octane workers so the config isn't cached stale:
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # from `stripe listen` in dev
Add Laravel\Cashier\Billable to your App\Models\User (and use HasFactory-style override via Cashier::useCustomerModel() if billables aren't User). The published create_customer_columns migration adds stripe_id, pm_type, pm_last_four, trial_ends_at to users. If you have a non-standard users table, this is the first thing to verify — most "Cashier doesn't work" reports start here.
Where to look first, in this order:
config/cashier.php — currency, webhook secret/tolerance, invoice renderer. This is the only config surface; everything else is fluent API.vendor/laravel/cashier/src/Billable.php and Concerns/ — the actual trait surface (createOrGetStripeCustomer, subscribed, subscription, invoices, …). It's readable and beats guessing from docs.POST /stripe/webhook, name cashier.webhook). Nothing stays in sync without it.First useful use case — a single subscription with a hosted checkout:
// routes/web.php
Route::post('/subscribe', function (Request $request) {
return $request->user()
->newSubscription('default', 'price_123')
->trialDays(14)
->checkout([
'success_url' => route('billing.success'),
'cancel_url' => route('billing.cancel'),
]);
});
checkout() returns a redirect (or a JSON payload with a url for Inertia/SPA). After the customer returns, the local state is only correct once the Stripe webhook has landed — see the gotchas.
Local loop: stripe listen --forward-to localhost:8000/stripe/webhook, then stripe trigger customer.subscription.created. Use Cashier::stripe() to reach the underlying SDK client when you need an endpoint Cashier hasn't wrapped.
Gate features on local state, not API calls. Cashier denormalizes subscription status into subscriptions / subscription_items and keeps it fresh via webhooks. Gate with $user->subscribed('default'), $user->subscribedToPrice('price_123', 'default'), $user->subscription('default')->onTrial(), ->onGracePeriod(). Avoid calling Stripe on every request; that's the whole point of the package.
One subscription type per feature. The first argument to newSubscription(), subscription(), subscribed() is a type string (default, team, addon) so a user can hold several concurrent subscriptions. Use it instead of adding another billable model.
Plan changes and proration. swap() swaps in place with proration by default; chain behaviour modifiers explicitly so intent survives refactors:
$user->subscription('default')
->noProrate() // or ->prorate() / ->alwaysInvoice()
->swap('price_pro');
$user->subscription('default')->swapAndInvoice('price_pro'); // bill immediately
$user->subscription('default')->swap('price_pro', ['quantity' => 5]);
Cancellation lifecycle. cancel() sets a grace period (onGracePeriod() → ends_at in the future, Stripe still bills until then), cancelNow() is immediate, resume() undoes a pending cancellation. Mirror this in UI: offer "Resume" while onGracePeriod(), "Cancel" otherwise. Add Cashier::keepPastDueSubscriptionsActive() if you want dunning to keep entitlement access during failed-payment retries.
Payment methods & SCA. $user->createOrGetStripeCustomer(), then addPaymentMethod($pmId) / updateDefaultPaymentMethod($pmId) and invoice() / invoicePrice() for one-offs. Always catch Laravel\Cashier\Exceptions\IncompletePayment and redirect to $e->payment->asStripePaymentIntent()'s next_action, or use Cashier::calculateTaxes() + Checkout and let Stripe host the 3DS dance. After a successful on-session confirmation, call updateDefaultPaymentMethodFromStripe() so local pm_type/pm_last_four refresh.
Webhooks are the integration seam. The default controller routes invoice.payment_succeeded, customer.subscription.deleted, etc. to handleInvoicePaymentSucceeded, handleCustomerSubscriptionDeleted, … — subclass it and register your own route with VerifyWebhookSignature middleware when you need side effects (provisioning, dunning emails):
Route::post('stripe/webhook', [App\Http\Controllers\StripeWebhookController::class, 'handleWebhook'])
->middleware(\Laravel\Cashier\Http\Middleware\VerifyWebhookSignature::class);
Put Cashier::ignoreRoutes() in a provider so the package route doesn't shadow yours.
Async safety. Any webhook handler that mutates state should be idempotent (v15.7.1 fixed a duplicate customer.subscription.created bug precisely because Stripe retries). Wrap handlers in transactions and key side effects off Stripe IDs, or push the work to a queued job with ShouldBeUnique.
Testing. No first-party fake ships with Cashier, so tests either mock Cashier::stripe() via the container, or hit Stripe's test mode. For webhook tests, generate a valid header with \Stripe\Webhook::generateTestHeaderString() and POST the raw body. For time travel on trials, use Stripe test clocks rather than Carbon::setTestNow() — Cashier reads its own columns but Stripe's clock drives the actual object transitions.
subscribed() reads the database. Forgetting STRIPE_WEBHOOK_SECRET, running stripe listen against the wrong port, or a 500 in your handler silently desyncs users. During a webhook deploy, reconcile with $user->syncSubscriptions() / re-fetch from Stripe rather than trusting the table.VerifyWebhookSignature rejects on secret mismatch or clock skew — cashier.webhook.tolerance (default 300s) bites on Docker/VM hosts with drifted clocks. Also confirm your queue isn't retrying an old, expired payload.stripe/* from CSRF by default, so keep the default path or add the URI to validateCsrfTokens(except: [...]) yourself.stripe_id may be null. asStripeCustomer(), defaultPaymentMethod(), and invoice helpers throw when the user was never synced; call createOrGetStripeCustomer() first or guard with $user->hasStripeId().trial_ends_at with no card. newSubscription()->trialDays() plus checkout() with payment_method_collection => 'if_required' is the combination that gives "no card on trial". Cashier clears the generic trial_ends_at column once a real subscription is created — don't set both.swap() on metered prices behaves differently. For usage-based billing use incrementAndInvoice() / reportUsage() on the item, or the newer meter-based API (v15.6.0); proration flags don't apply.cashier.php or Stripe env vars, run php artisan config:clear. With Octane, restart workers — Cashier resolves the Stripe client lazily but caches the instance.Cashier::useCurrency('eur', '€') in a service provider; never hardcode € in Blade. Zero-decimal currencies (JPY, KRW) are handled by the formatter, not by your math.dompdf/dompdf and are slow; generate them queued, not in the request that renders a billing history page.model:prune; if you keep historical billing data, either preserve those rows or call Cashier::preventPruningSubscriptions().Cashier::useSubscriptionModel(), useSubscriptionItemModel(), useCustomerModel(), useInvoiceModel() are the supported hooks — extend before you start reaching into Cashier::$subscriptionModel directly. Everything you can't reach fluently is still available via Cashier::stripe().How can I help you explore Laravel packages today?