Why Laravel Cashier?
Building SaaS billing from scratch — handling Stripe checkout sessions, webhook verification, subscription state, invoice PDFs, trial logic — is weeks of work and easy to get wrong. Laravel Cashier wraps all of this into an elegant API that plugs directly into your Eloquent User model.
With Cashier you get: one-line subscription creation, automatic webhook handling, built-in trial periods, grace period cancellations, invoice generation, proration, and coupon support — all battle-tested and maintained by the Laravel team.
Laravel 11, PHP 8.3, laravel/cashier v15, Stripe API v3, MySQL 8. Assumes Sanctum auth already configured. Uses Stripe test mode — swap keys for production.
Subscription Lifecycle
Install & Configure Cashier
# Install Cashier composer require laravel/cashier # Publish + run migrations (creates subscriptions, subscription_items tables) php artisan vendor:publish --tag="cashier-migrations" php artisan migrate # Install Stripe CLI for local webhook testing brew install stripe/stripe-cli/stripe stripe login
STRIPE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxx # Publishable key STRIPE_SECRET=sk_test_xxxxxxxxxxxxxxxxxxxx # Secret key STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxx # From Stripe dashboard or CLI CASHIER_CURRENCY=usd CASHIER_CURRENCY_LOCALE=en
use Laravel\Cashier\Billable; class User extends Authenticatable { use Billable; // ← this one trait adds everything // Now User has: subscribe(), subscription(), invoices(), // onTrial(), subscribed(), cancel(), swap() etc. }
Define Pricing Plans
Create your products and prices in the Stripe Dashboard (or via API). Copy the price_xxx IDs into your config. Never hardcode plan details in code — keep them in config so you can change prices without a deploy.
- 5 Projects
- 10 GB Storage
- Email Support
- API Access
- Custom Domain
- Unlimited Projects
- 100 GB Storage
- Priority Support
- API Access
- Custom Domain
- Unlimited Projects
- 1 TB Storage
- 24/7 Support
- API Access
- Custom Domain
return [ 'starter' => [ 'name' => 'Starter', 'stripe_id' => 'price_1234567890starter', // from Stripe dashboard 'price' => 9, 'trial_days' => 14, ], 'pro' => [ 'name' => 'Pro', 'stripe_id' => 'price_1234567890pro', 'price' => 29, 'trial_days' => 14, ], 'business' => [ 'name' => 'Business', 'stripe_id' => 'price_1234567890business', 'price' => 79, 'trial_days' => 14, ], ];
Stripe Checkout & Subscribe
The cleanest approach: use Stripe Checkout (hosted payment page) — handles card validation, 3D Secure, SCA compliance, and saves you building a card form.
class SubscriptionController extends Controller { /** Create Stripe Checkout session → redirect to hosted page */ public function checkout(Request $request, string $plan): RedirectResponse { $planConfig = config("plans.{$plan}"); abort_unless($planConfig, 404); // Ensure user has a Stripe customer record $request->user()->createOrGetStripeCustomer([ 'email' => $request->user()->email, 'name' => $request->user()->name, ]); // Build checkout session with trial return $request->user() ->newSubscription('default', $planConfig['stripe_id']) ->trialDays($planConfig['trial_days']) ->allowPromotionCodes() ->checkout([ 'success_url' => route('billing.success'), 'cancel_url' => route('billing.cancel'), 'metadata' => ['plan' => $plan], ]); } /** Swap plan (upgrade/downgrade) */ public function swap(Request $request, string $plan): JsonResponse { $planConfig = config("plans.{$plan}"); abort_unless($planConfig && $request->user()->subscribed('default'), 422); // Swap immediately with proration $request->user() ->subscription('default') ->swap($planConfig['stripe_id']); return $this->success(null, "Switched to {$plan} plan"); } /** Cancel — at end of billing period (grace period) */ public function cancel(Request $request): JsonResponse { $request->user()->subscription('default')->cancel(); return $this->success(null, 'Subscription will cancel at period end'); } /** Resume a canceled (on grace period) subscription */ public function resume(Request $request): JsonResponse { $request->user()->subscription('default')->resume(); return $this->success(null, 'Subscription resumed'); } }
Webhooks — The Critical Part
Webhooks are where most Cashier implementations break in production. Stripe sends events to your server for every billing lifecycle change — you must handle these or your subscription state will be out of sync.
Forgetting to handle customer.subscription.deleted means users keep access after their subscription expires. Always verify webhook signatures and handle every critical event.
| Stripe Event | What Happened | Action |
|---|---|---|
| checkout.session.completed | User paid, subscription starts | ✓ Activate user |
| customer.subscription.updated | Plan changed, trial ended | ↻ Sync subscription |
| customer.subscription.deleted | Subscription expired/cancelled | ✗ Revoke access |
| invoice.payment_succeeded | Monthly renewal paid | ✓ Extend period |
| invoice.payment_failed | Card declined | ⚠ Email user |
| customer.subscription.trial_will_end | Trial ends in 3 days | ⚠ Send reminder |
Cashier automatically handles most webhook events. Register the built-in webhook route, and extend the WebhookController for custom logic:
use Laravel\Cashier\Http\Controllers\WebhookController; // Cashier handles all Stripe events automatically on this route Route::post('/stripe/webhook', WebhookController::class) ->name('cashier.webhook'); // ⚠ Exclude from CSRF middleware in bootstrap/app.php or VerifyCsrfToken: // protected $except = ['/stripe/webhook'];
use Laravel\Cashier\Http\Controllers\WebhookController as CashierWebhook; class StripeWebhookController extends CashierWebhook { /** Trial ending soon — send reminder email */ public function handleCustomerSubscriptionTrialWillEnd(array $payload): Response { $user = $this->getUserByStripeId($payload['data']['object']['customer']); if ($user) { Mail::to($user)->send(new TrialEndingEmail($user)); } return $this->successMethod(); } /** Payment failed — notify user to update card */ public function handleInvoicePaymentFailed(array $payload): Response { $user = $this->getUserByStripeId($payload['data']['object']['customer']); if ($user) { Mail::to($user)->send(new PaymentFailedEmail($user)); } return $this->successMethod(); } }
# Forward Stripe events to your local server stripe listen --forward-to localhost:8000/stripe/webhook # Test specific events: stripe trigger customer.subscription.deleted stripe trigger invoice.payment_failed
Subscription Gates & Middleware
Gate your features based on subscription status using Laravel's built-in helpers. Never query Stripe on every request — Cashier caches subscription state in your database.
class RequiresSubscription { public function handle(Request $request, Closure $next, string ...$plans) { $user = $request->user(); // Allow trial users full access if ($user->onTrial('default')) { return $next($request); } // Check active subscription to specific plan if (!empty($plans)) { foreach ($plans as $plan) { if ($user->subscribedToPrice(config("plans.{$plan}.stripe_id"))) { return $next($request); } } } // Any active subscription if ($user->subscribed('default')) { return $next($request); } return response()->json(['message' => 'Subscription required'], 402); } } // Usage in routes: // Route::middleware('subscribed:pro,business')->group(fn() => ...);
Invoices & Billing Portal
class BillingController extends Controller { /** Download invoice PDF */ public function downloadInvoice(Request $request, string $invoiceId): Response { return $request->user()->downloadInvoice($invoiceId, [ 'vendor' => 'CodeCraft Systems', 'product' => 'Pro Subscription', ]); } /** List all invoices (for billing history page) */ public function invoices(Request $request): JsonResponse { $invoices = $request->user()->invoices()->map(fn($inv) => [ 'id' => $inv->id, 'date' => $inv->date()->toDateString(), 'amount' => $inv->total(), 'paid' => $inv->paid, 'pdf' => route('billing.invoice.download', $inv->id), ]); return $this->success($invoices); } /** Redirect to Stripe's hosted billing portal (update card, cancel, etc.) */ public function portal(Request $request): RedirectResponse { return $request->user()->redirectToBillingPortal( route('dashboard') // return URL after portal ); } }
The redirectToBillingPortal() method sends users to Stripe's hosted portal — they can update cards, view invoices, and cancel all without you writing a single UI component. Enable it in your Stripe Dashboard under Settings → Billing → Customer portal.
Check Subscription Status
$user = Auth::user(); // Is subscribed to any plan? $user->subscribed('default'); // true // Is on trial? $user->onTrial('default'); // true during trial $user->onGenericTrial(); // true if trial, no subscription needed // Is subscribed to a specific price? $user->subscribedToPrice('price_pro_id'); // true // Is subscription cancelled but still on grace period? $user->subscription('default')->onGracePeriod(); // true // Get subscription end date $user->subscription('default')->ends_at; // Carbon or null // Get Stripe subscription object $user->subscription('default')->asStripeSubscription(); // Full status check for API response return [ 'subscribed' => $user->subscribed('default'), 'on_trial' => $user->onTrial('default'), 'on_grace' => $user->subscription('default')?->onGracePeriod(), 'trial_ends_at' => $user->trialEndsAt('default')?->toDateString(), 'renews_at' => $user->subscription('default')?->asStripeSubscription()->current_period_end, ];
When a user cancels, subscribed() returns false but they still paid until the period ends. Always check onGracePeriod() too — or use subscribed() || onGracePeriod() to allow access until their paid period truly expires.
Cashier + Stripe Checkout is the fastest path to production SaaS billing. You get PCI compliance, 3D Secure, global payment methods, and SCA handling for free by delegating to Stripe's hosted UI.
Webhooks are non-negotiable — local state drifts from Stripe without them. Use stripe listen in development and register your production webhook URL in the Stripe Dashboard under Developers → Webhooks.
The Billing Portal is underused — redirectToBillingPortal() replaces an entire billing settings UI. Let Stripe handle card updates, invoice history, and cancellation. Your job is just the product.