We earn commissions when you shop through the links below.
If you’re building a SaaS product on Laravel, this Laravel Cashier subscription billing tutorial is the fastest path from zero to working recurring payments. Laravel Cashier wraps Stripe’s API in a fluent, expressive interface that handles subscriptions, invoices, payment methods, and webhook events — without you having to touch raw Stripe SDK calls every five minutes. Let’s build it properly.
What We’re Building
By the end of this guide you’ll have a Laravel app that can:
- Create Stripe customers on user registration
- Subscribe users to monthly and yearly plans
- Handle subscription upgrades, downgrades, and cancellations
- Process Stripe webhooks to keep your database in sync
- Gate features behind active subscription checks
Prerequisites
You need a Laravel 11+ application, a Stripe account (test mode is fine), and PHP 8.2+. I’m assuming you already have a User model and basic authentication in place — Breeze or Jetstream both work fine.
Step 1: Install Laravel Cashier
Install the Cashier package via Composer:
composer require laravel/cashierThen publish and run the migrations:
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrateThis creates the subscriptions, subscription_items, and adds Stripe-specific columns to your users table (stripe_id, pm_type, pm_last_four, trial_ends_at).
Step 2: Configure Your Environment
Add your Stripe keys to .env:
STRIPE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_SECRET=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
CASHIER_CURRENCY=usdIn config/cashier.php (publish it with php artisan vendor:publish --tag="cashier-config") you can set the currency, payment confirmation behavior, and the Stripe API version.
Step 3: Prepare the User Model
Add the Billable trait to your User model:
<?php
namespace App\Models;
use Laravel\Cashier\Billable;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
use Billable;
// your existing code
}That single trait gives your users 40+ billing-related methods. Seriously, that’s it for the model setup.
Step 4: Create Products and Prices in Stripe
Head to your Stripe dashboard and create a product (e.g., “Pro Plan”) with two prices: one monthly at $29/month and one yearly at $290/year. Copy the price IDs — they look like price_1OxxxxxxxxxxxxxxxxxxxxXX. Store them as constants or config values:
// config/billing.php
return [
'plans' => [
'pro_monthly' => env('STRIPE_PRICE_PRO_MONTHLY'),
'pro_yearly' => env('STRIPE_PRICE_PRO_YEARLY'),
],
];Step 5: Build the Subscription Controller
This is the core of the Laravel Cashier subscription billing tutorial. Here’s a controller that handles the full subscription lifecycle:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Laravel\Cashier\Exceptions\IncompletePayment;
class BillingController extends Controller
{
public function subscribe(Request $request)
{
$request->validate([
'plan' => 'required|in:pro_monthly,pro_yearly',
'payment_method' => 'required|string',
]);
$user = $request->user();
$price = config("billing.plans.{$request->plan}");
try {
$user->newSubscription('default', $price)
->trialDays(14)
->create($request->payment_method);
} catch (IncompletePayment $e) {
return redirect()->route(
'cashier.payment',
[$e->payment->id, 'redirect' => route('dashboard')]
);
}
return redirect()->route('dashboard')
->with('success', 'Subscription created!');
}
public function swap(Request $request)
{
$request->validate(['plan' => 'required|in:pro_monthly,pro_yearly']);
$price = config("billing.plans.{$request->plan}");
$request->user()
->subscription('default')
->swap($price);
return back()->with('success', 'Plan updated!');
}
public function cancel(Request $request)
{
$request->user()->subscription('default')->cancel();
return back()->with('success', 'Subscription cancelled at period end.');
}
public function resume(Request $request)
{
$request->user()->subscription('default')->resume();
return back()->with('success', 'Subscription resumed!');
}
public function portal(Request $request)
{
return $request->user()->redirectToBillingPortal(
route('dashboard')
);
}
}Notice the trialDays(14) call — Cashier handles the trial period and won’t charge the card until day 15. The cancel() method cancels at period end by default, not immediately, which is the right UX for most SaaS products. Use cancelNow() if you need immediate cancellation.
Step 6: Set Up Webhooks
Webhooks are where most implementations go wrong. Stripe sends events asynchronously, so your database needs to react to things like failed payments or subscription renewals even when the user isn’t actively using your app.
Register the Cashier webhook route in your routes/web.php:
use Laravel\Cashier\Http\Controllers\WebhookController;
Route::post('/stripe/webhook', WebhookController::class);
Exclude this route from CSRF protection in your bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'stripe/webhook',
]);
})In your Stripe dashboard, add https://yourdomain.com/stripe/webhook as a webhook endpoint and listen for at minimum: invoice.payment_succeeded, invoice.payment_failed, customer.subscription.deleted, customer.subscription.updated.
For local development, use the Stripe CLI:
stripe listen --forward-to localhost:8000/stripe/webhookCashier handles all these events automatically and keeps your subscriptions table in sync. If you need custom logic on a webhook event, extend the WebhookController:
<?php
namespace App\Http\Controllers;
use Laravel\Cashier\Http\Controllers\WebhookController as CashierWebhookController;
class WebhookController extends CashierWebhookController
{
public function handleInvoicePaymentFailed(array $payload): void
{
$user = $this->getUserByStripeId($payload['data']['object']['customer']);
if ($user) {
// Send dunning email, flag account, etc.
$user->notify(new PaymentFailedNotification());
}
}
}Step 7: Gate Features by Subscription
Protect routes and features using Cashier’s subscription checks:
// In a route middleware or controller
if (! $user->subscribed('default')) {
return redirect()->route('billing.index');
}
// Check if on a specific price
if ($user->subscribedToPrice(config('billing.plans.pro_yearly'), 'default')) {
// yearly subscriber perks
}
// In Blade templates
@if(auth()->user()->subscribed('default'))
<x-pro-feature />
@else
<x-upgrade-banner />
@endifYou can also define a reusable middleware:
// app/Http/Middleware/RequiresActiveSubscription.php
public function handle(Request $request, Closure $next)
{
if (! $request->user()?->subscribed('default')) {
return redirect()->route('billing.index')
->with('error', 'An active subscription is required.');
}
return $next($request);
}Step 8: Handle the Payment Method Collection
You need to collect a payment method before subscribing. Use Stripe.js and a payment intent. Cashier provides a helper to generate a setup intent:
// In your billing page controller
public function index(Request $request)
{
$intent = $request->user()->createSetupIntent();
return view('billing.index', compact('intent'));
}Then in your Blade view, use Stripe.js to confirm the setup intent and pass the resulting payment method ID to your subscribe endpoint. The Cashier docs have a complete frontend example — the key is you never want raw card numbers hitting your server.
Deploying Your Billing App
When you’re ready to ship, you need a reliable host that can handle webhooks reliably (no cold starts, no sleeping servers). I deploy most of my Laravel SaaS projects to Railway — it supports PHP natively, has zero-downtime deploys, and the persistent server model means Stripe webhooks always hit a live process. For higher-traffic apps or when you want more control over your server configuration, DigitalOcean App Platform or a Droplet with Laravel Forge works extremely well.
Make sure your production environment has the correct STRIPE_WEBHOOK_SECRET set — Cashier verifies webhook signatures automatically and will reject unsigned requests.
Common Gotchas
- Webhook signature failures: Usually caused by a missing or wrong
STRIPE_WEBHOOK_SECRET. Each Stripe webhook endpoint has its own secret — don’t reuse the one from your local Stripe CLI session. - IncompletePayment exceptions: 3D Secure cards require additional authentication. Always wrap
create()in a try/catch and redirect to Cashier’s payment confirmation page. - Trial vs. subscribed:
onTrial()andsubscribed()are different. A user on trial is also considered subscribed unless you check explicitly. - Queue your webhook handler: In production, set
CASHIER_WEBHOOK_TOLERANCEand consider queuing webhook processing so a slow database operation doesn’t cause Stripe to retry.
Final Thoughts
This Laravel Cashier subscription billing tutorial covers the full lifecycle — install, configure, subscribe, webhook, gate, and deploy. Cashier abstracts an enormous amount of Stripe complexity, but it doesn’t hide it so much that you lose control. The billing portal redirect alone saves hours of building invoice and payment method management UI from scratch.
If you want to go deeper on SaaS architecture patterns in Laravel, Udemy has several well-reviewed courses that pair nicely with what you’ve built here. Start shipping.