Laravel Cashier is the official Laravel package for subscription billing with Stripe. It wraps the Stripe Billing API in Eloquent style methods, so starting a paid plan looks like $user->newSubscription('default', 'price_pro')->create($paymentMethod) instead of a pile of raw API calls, webhook parsers, and status flags you maintain by hand.
Most billing bugs are not payment bugs. They are state bugs. The charge clears, Stripe is happy, and your app still shows a locked feature because nothing told your database that the subscription went active.
Stripe can handle the money. What eats the week is the plumbing around it, including customer records, trials, proration on plan changes, grace periods after cancellation, failed payment retries, and the webhook that keeps all of it in sync. This Laravel Cashier guide walks that plumbing end to end, from install to the tests you run before you take a real card. You get working code at each stage, the state helpers worth memorizing, and the mistakes that turn into support tickets three months after launch.
Key Takeaways
What Laravel Cashier does and what it leaves to you
Laravel Cashier is a first-party package maintained by the Laravel team. Add one trait and your User model becomes a Stripe customer with subscriptions, payment methods, and invoices hanging off it.
Cashier takes care of the following:
Creating and storing the Stripe customer, plus the
subscriptionsandsubscription_itemstables.Subscription creation, trials, seat quantities, coupons, and promotion codes.
Plan swaps with or without proration, cancellations, grace periods, and resumes.
A signed webhook endpoint that keeps local subscription rows in sync with Stripe.
Invoice listing, invoice PDFs, and a redirect into the Stripe billing portal.
Payment confirmation pages for cards that require strong customer authentication.
Four things stay your job:
Product and price setup. You create products and prices in the Stripe dashboard or API. Cashier consumes price IDs, it does not invent them.
Entitlements. Cashier tells you a subscription is valid. What that unlocks inside your app is your domain logic.
Pricing page and checkout UX. Cashier hands back a redirect or a payment intent. The page around it is yours.
Tax, dunning policy, and revenue reporting. Stripe offers these, and you opt in deliberately.
There are two Cashier packages, one for Stripe and one for Paddle. Pick based on who is the merchant of record.
Approach | Best for | What you still write yourself |
|---|---|---|
Cashier Stripe | Card and wallet payments where you own tax and invoicing | Pricing page, entitlements, webhook side effects |
Cashier Paddle | Selling globally with a merchant of record that handles sales tax and VAT | The same, with Paddle specific checkout |
Stripe PHP SDK on its own | Unusual billing models Cashier does not represent well | Customer sync, subscription state, webhooks, invoices, SCA flows |
For a standard product with monthly and yearly tiers, Cashier Stripe is the shortest path. The official Laravel Cashier documentation tracks the package release by release, so treat it as your reference while this guide gives you the order of operations. If you are building multi-tenant SaaS subscriptions, decide on day one whether the billable model is the user or the team, because changing that later means a data migration and a reconciliation script.
Wiring all of this by hand costs a couple of days the first time you do it. If you would rather describe the billing feature and review the result, Get started free and have StackMivo generate the plans config, Billable wiring, and subscription controller as production-ready Laravel apps you own outright.
Install and configure Laravel Cashier with Stripe
Cashier installs as a Composer package and publishes its own migrations. Three commands get the schema in place.
composer require laravel/cashier
php artisan vendor:publish --tag=cashier-migrations
php artisan migrateThose migrations add billing columns to your users table, including stripe_id, pm_type, pm_last_four, and trial_ends_at. They also create the subscriptions and subscription_items tables that mirror what Stripe knows. Check the compatibility table on the Cashier Stripe repository before a Laravel upgrade, because each major Cashier release pins a Stripe API version.
Next, add the Billable trait to whichever model pays you.
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Cashier\Billable;
class User extends Authenticatable
{
use Billable;
}Then add your keys. Use test keys locally and never let a live secret near a staging environment.
STRIPE_KEY=pk_test_your_publishable_key
STRIPE_SECRET=sk_test_your_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_signing_secret
CASHIER_CURRENCY=usdOne habit saves real pain later. Keep price IDs in config instead of scattering them through controllers and Blade files, because test and live prices are different strings.
return [
'plans' => [
'pro-monthly' => env('STRIPE_PRICE_PRO_MONTHLY'),
'pro-yearly' => env('STRIPE_PRICE_PRO_YEARLY'),
],
];Create your first subscription with Stripe Checkout
You have two ways to collect a card. Stripe Checkout redirects the customer to a Stripe hosted page, which keeps your PCI scope small and gets you live fastest. Stripe Elements keeps the form on your domain and gives you full control of the design.
The hosted Checkout route
Cashier's subscription builder returns a redirectable Checkout session, so a controller action is all you need.
public function checkout(Request $request)
{
return $request->user()
->newSubscription('default', config('billing.plans.pro-monthly'))
->trialDays(7)
->allowPromotionCodes()
->checkout([
'success_url' => route('billing.success'),
'cancel_url' => route('pricing'),
]);
}The first argument, default, is the subscription type. It is an internal label, not a Stripe value, and it lets one customer hold several concurrent subscriptions such as default and addon-storage.
The Elements route
If you collect the payment method yourself, pass the resulting method ID straight into create().
$request->user()
->newSubscription('default', config('billing.plans.pro-monthly'))
->create($request->input('payment_method'));Trials work two ways, and the difference matters. trialDays(7) creates a trial on the Stripe subscription itself, which means the customer supplies a card up front. For a card free trial, set trial_ends_at on the user at registration and check $user->onTrial() before any subscription exists at all.
Gate features by subscription state
This is where most teams introduce their own bug. They add an is_pro column, set it after checkout, and then forget to clear it when a card fails at 3am on renewal day. Read state from Cashier instead. It already knows.
$user->subscribed('default')returns true for active, trialing, and grace period subscriptions.$user->subscribedToPrice($priceId, 'default')checks a specific tier.$user->subscription('default')->onTrial()separates trialists from payers.$user->subscription('default')->onGracePeriod()covers customers who cancelled but already paid for the month.$user->subscription('default')->hasIncompletePayment()flags cards stuck waiting on 3D Secure.
Wrap the check in middleware so route groups stay declarative.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class EnsureUserIsSubscribed
{
public function handle(Request $request, Closure $next)
{
if (! $request->user()?->subscribed('default')) {
return redirect()->route('pricing');
}
return $next($request);
}
}Register the alias in bootstrap/app.php, then apply it with Route::middleware(['auth', 'subscribed']). For finer control inside a feature, combine the Cashier check with a Policy so plan level and record ownership are answered in one place.
Generic AI autocomplete tends to guess here, because it does not know Cashier's state machine and will happily invent a method that never existed. An assistant built on Laravel-native intelligence writes the middleware, the Policy, and the Pest coverage using real Cashier methods instead of plausible fiction.
Handle webhooks and failed payments
Everything above covers the moment a customer pays. Webhooks cover the other 99% of the subscription lifecycle, including renewals, failures, cancellations from the Stripe portal, and cards that expire quietly.
Cashier registers a controller at /stripe/webhook and handles the core events for you. Point Stripe at it, then exclude the path from CSRF verification.
php artisan cashier:webhook
stripe listen --forward-to localhost:8000/stripe/webhook
stripe trigger invoice.payment_failed->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'stripe/*',
]);
})For your own side effects, listen to Cashier's WebhookReceived event rather than editing the package controller.
use Laravel\Cashier\Events\WebhookReceived;
class HandleStripeWebhook
{
public function handle(WebhookReceived $event): void
{
if ($event->payload['type'] === 'invoice.payment_failed') {
// Notify the customer and flag the account for follow up.
}
}
}A two person team building a reporting tool tested checkout with Stripe test cards, watched the redirect succeed, and shipped that afternoon. Their first real cancellation came through the Stripe billing portal, and the customer kept full access for another month. No code was broken. The webhook endpoint simply did not exist yet, so Stripe had nowhere to deliver customer.subscription.deleted. Ten minutes with the Artisan command and a signing secret in .env closed the hole permanently.
Decide your dunning behavior deliberately too. By default a past_due subscription is no longer considered active, so access ends the moment a renewal fails. Call Cashier::keepPastDueSubscriptionsActive() in a service provider if you would rather give Stripe's retry schedule a few days to recover the payment. The Stripe webhooks documentation lists every event you can subscribe to.
Change plans, cancel, and resume subscriptions
Upgrades, downgrades, and cancellations are one line each, and the defaults are sensible.
$subscription = $user->subscription('default');
$subscription->swap(config('billing.plans.pro-yearly')); // prorated by default
$subscription->noProrate()->swap(config('billing.plans.pro-yearly'));
$subscription->updateQuantity(5); // per seat pricing
$subscription->cancel(); // ends at period end
$subscription->cancelNow(); // ends immediately
$subscription->resume(); // only during the grace periodAn agency developer inherited a client app where cancelling deleted the subscription row outright. Customers who cancelled on day two of a paid month lost access the same afternoon, then emailed support asking for the time they had already bought. The repair was two lines. Call cancel() instead of deleting the record, and read access through subscribed(), which stays true while ends_at sits in the future. The early lockout tickets stopped because the app finally agreed with the invoice.
For self service, skip building a billing UI at first and send customers to Stripe with $user->redirectToBillingPortal(route('dashboard')). Invoice history is available through $user->invoices(), and downloadInvoice() renders a PDF once you install dompdf/dompdf. Billing is usually one feature inside a bigger app, and the surrounding CRUD follows the same conventions covered in our walkthrough on how to build a CRM with Laravel AI.
Test Laravel Cashier billing before you go live
Billing code fails in the paths nobody clicks in development. Work through this list in Stripe test mode before you switch the keys.
Subscribe with card
4242 4242 4242 4242and confirm the localsubscriptionsrow matches the Stripe dashboard.Subscribe with a 3D Secure test card and confirm the payment confirmation page appears and clears.
Run
stripe trigger invoice.payment_failedand check what your app shows a past due customer.Cancel from the Stripe billing portal and confirm access survives until the period ends, then disappears.
Swap monthly to yearly and read the proration line on the resulting invoice.
Unit tests can cover the state logic without touching the network, which keeps the suite fast.
it('keeps access during the cancellation grace period', function () {
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test',
'stripe_status' => 'active',
'stripe_price' => 'price_pro_monthly',
'quantity' => 1,
'ends_at' => now()->addDays(5),
]);
expect($user->subscribed('default'))->toBeTrue()
->and($user->subscription('default')->onGracePeriod())->toBeTrue();
});Full checkout flows still need Stripe test mode, since Cashier is talking to a real API. Keep the network tests in a separate group so your everyday suite stays quick. You can also Try StackMivo Now to generate the Pest coverage alongside the billing feature, which is the part most teams postpone and then never write.
Where to go from here
Laravel Cashier turns Stripe subscription billing into ordinary Laravel work. Install it, add the Billable trait, keep price IDs in config, and let the package own the schema. Send customers through Checkout, gate features with subscribed() instead of a homemade flag, and register the webhook on day one so cancellations and failed renewals reach your database without a human in the loop.
Grace periods are the detail worth getting right. A cancelled customer who paid through the end of the month should keep working until that date, and Cashier models this correctly if you use cancel() and read state through its helpers. Everything else, including swaps, seat quantities, invoices, and the billing portal, is a single method call once the foundation is solid.
Start with one plan and one price. Get a real subscription through test mode today, confirm the webhook fires, then add the yearly tier, the seat pricing, and the coupons. Billing rewards teams who ship a small correct version first, and Laravel Cashier makes that first version a short afternoon rather than a sprint.
Build your Laravel app with AI
Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.
Get started free