Laravel Cashier Subscription Billing Tutorial: From Zero to Recurring Revenue

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/cashier

Then publish and run the migrations:

php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate

This 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=usd

In 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/webhook

Cashier 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 />
@endif

You 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() and subscribed() are different. A user on trial is also considered subscribed unless you check explicitly.
  • Queue your webhook handler: In production, set CASHIER_WEBHOOK_TOLERANCE and 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.