Building SaaS with Laravel (Part 3): Billing with Stripe Cashier

· 15 min read

In Part 2, we built multi-tenancy. Now it's time for the core of every SaaS: getting paid. We'll use Laravel Cashier with Stripe to build a complete subscription billing system.

1. Setting Up Stripe

Create Products & Prices on Stripe

First, create products on the Stripe Dashboard (or via API). We'll have 3 plans:

Plan Monthly Yearly Features
Starter $9/month $90/year 3 projects, 2 members
Professional $29/month $290/year Unlimited projects, 10 members
Business $79/month $790/year Unlimited everything + API access

Environment Configuration

STRIPE_KEY=pk_test_xxxxx
STRIPE_SECRET=sk_test_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx

# Price IDs from Stripe Dashboard
STRIPE_PRICE_STARTER_MONTHLY=price_starter_monthly
STRIPE_PRICE_STARTER_YEARLY=price_starter_yearly
STRIPE_PRICE_PRO_MONTHLY=price_pro_monthly
STRIPE_PRICE_PRO_YEARLY=price_pro_yearly
STRIPE_PRICE_BUSINESS_MONTHLY=price_business_monthly
STRIPE_PRICE_BUSINESS_YEARLY=price_business_yearly

Plans Config File

// config/plans.php
<?php

return [
    'starter' => [
        'name' => 'Starter',
        'prices' => [
            'monthly' => env('STRIPE_PRICE_STARTER_MONTHLY'),
            'yearly' => env('STRIPE_PRICE_STARTER_YEARLY'),
        ],
        'limits' => [
            'projects' => 3,
            'members' => 2,
            'storage_mb' => 500,
            'api_access' => false,
        ],
    ],
    'professional' => [
        'name' => 'Professional',
        'prices' => [
            'monthly' => env('STRIPE_PRICE_PRO_MONTHLY'),
            'yearly' => env('STRIPE_PRICE_PRO_YEARLY'),
        ],
        'limits' => [
            'projects' => -1, // unlimited
            'members' => 10,
            'storage_mb' => 5000,
            'api_access' => false,
        ],
    ],
    'business' => [
        'name' => 'Business',
        'prices' => [
            'monthly' => env('STRIPE_PRICE_BUSINESS_MONTHLY'),
            'yearly' => env('STRIPE_PRICE_BUSINESS_YEARLY'),
        ],
        'limits' => [
            'projects' => -1,
            'members' => -1,
            'storage_mb' => 50000,
            'api_access' => true,
        ],
    ],
];

2. Database Schema for Billing

Cashier creates its own migrations, but we need an additional table to track plans:

// database/migrations/2026_03_17_000001_create_plans_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('plans', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->string('stripe_monthly_price_id');
            $table->string('stripe_yearly_price_id');
            $table->decimal('monthly_price', 8, 2);
            $table->decimal('yearly_price', 8, 2);
            $table->json('limits');
            $table->boolean('is_active')->default(true);
            $table->integer('sort_order')->default(0);
            $table->timestamps();
        });

        // Add plan reference to tenants
        Schema::table('tenants', function (Blueprint $table) {
            $table->foreignId('plan_id')
                ->nullable()
                ->after('status')
                ->constrained()
                ->nullOnDelete();
        });
    }

    public function down(): void
    {
        Schema::table('tenants', function (Blueprint $table) {
            $table->dropConstrainedForeignId('plan_id');
        });
        Schema::dropIfExists('plans');
    }
};

Plans Seeder

// database/seeders/PlanSeeder.php
<?php

namespace Database\Seeders;

use App\Domain\Billing\Models\Plan;
use Illuminate\Database\Seeder;

class PlanSeeder extends Seeder
{
    public function run(): void
    {
        $plans = config('plans');

        foreach ($plans as $slug => $config) {
            Plan::updateOrCreate(
                ['slug' => $slug],
                [
                    'name' => $config['name'],
                    'stripe_monthly_price_id' => $config['prices']['monthly'],
                    'stripe_yearly_price_id' => $config['prices']['yearly'],
                    'monthly_price' => match ($slug) {
                        'starter' => 9.00,
                        'professional' => 29.00,
                        'business' => 79.00,
                    },
                    'yearly_price' => match ($slug) {
                        'starter' => 90.00,
                        'professional' => 290.00,
                        'business' => 790.00,
                    },
                    'limits' => $config['limits'],
                    'is_active' => true,
                    'sort_order' => match ($slug) {
                        'starter' => 1,
                        'professional' => 2,
                        'business' => 3,
                    },
                ]
            );
        }
    }
}

3. Billable Model

In SaaS, typically the Tenant (organization) is the billable entity, not the User:

// app/Domain/Tenant/Models/Tenant.php - updated
<?php

declare(strict_types=1);

namespace App\Domain\Tenant\Models;

use App\Domain\Billing\Models\Plan;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Cashier\Billable;

class Tenant extends Model
{
    use Billable, SoftDeletes;

    protected $fillable = [
        'name',
        'slug',
        'domain',
        'owner_id',
        'plan_id',
        'settings',
        'status',
        'trial_ends_at',
    ];

    protected $casts = [
        'settings' => 'array',
        'trial_ends_at' => 'datetime',
    ];

    public function plan(): BelongsTo
    {
        return $this->belongsTo(Plan::class);
    }

    public function onPlan(string $planSlug): bool
    {
        return $this->plan?->slug === $planSlug;
    }

    public function planLimit(string $feature): int
    {
        return $this->plan?->limits[$feature] ?? 0;
    }

    public function hasFeature(string $feature): bool
    {
        $limit = $this->planLimit($feature);

        return $limit === -1 || $limit > 0;
    }

    // ... keep existing methods
}

Important: Cashier defaults to the users table. We need to configure it to use tenants:

// config/cashier.php - add or update
'model' => App\Domain\Tenant\Models\Tenant::class,

And run Cashier migrations on the tenants table:

// database/migrations/2026_03_17_000002_add_cashier_columns_to_tenants.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('tenants', function (Blueprint $table) {
            $table->string('stripe_id')->nullable()->index();
            $table->string('pm_type')->nullable();
            $table->string('pm_last_four', 4)->nullable();
            $table->timestamp('trial_ends_at')->nullable()->change();
        });
    }

    public function down(): void
    {
        Schema::table('tenants', function (Blueprint $table) {
            $table->dropColumn([
                'stripe_id',
                'pm_type',
                'pm_last_four',
            ]);
        });
    }
};

4. Plan Model

// app/Domain/Billing/Models/Plan.php
<?php

declare(strict_types=1);

namespace App\Domain\Billing\Models;

use Illuminate\Database\Eloquent\Model;

class Plan extends Model
{
    protected $fillable = [
        'name',
        'slug',
        'stripe_monthly_price_id',
        'stripe_yearly_price_id',
        'monthly_price',
        'yearly_price',
        'limits',
        'is_active',
        'sort_order',
    ];

    protected $casts = [
        'limits' => 'array',
        'monthly_price' => 'decimal:2',
        'yearly_price' => 'decimal:2',
        'is_active' => 'boolean',
    ];

    public function getLimit(string $feature): int
    {
        return $this->limits[$feature] ?? 0;
    }

    public function hasUnlimited(string $feature): bool
    {
        return $this->getLimit($feature) === -1;
    }

    public function stripePriceId(string $interval = 'monthly'): string
    {
        return $interval === 'yearly'
            ? $this->stripe_yearly_price_id
            : $this->stripe_monthly_price_id;
    }
}

5. Subscription Action

// app/Domain/Billing/Actions/CreateSubscriptionAction.php
<?php

declare(strict_types=1);

namespace App\Domain\Billing\Actions;

use App\Domain\Billing\Models\Plan;
use App\Domain\Tenant\Models\Tenant;
use Laravel\Cashier\Subscription;

class CreateSubscriptionAction
{
    public function execute(
        Tenant $tenant,
        Plan $plan,
        string $paymentMethod,
        string $interval = 'monthly',
    ): Subscription {
        // Create or retrieve Stripe customer
        if (! $tenant->hasStripeId()) {
            $tenant->createAsStripeCustomer([
                'name' => $tenant->name,
                'email' => $tenant->owner->email,
                'metadata' => [
                    'tenant_id' => $tenant->id,
                    'tenant_slug' => $tenant->slug,
                ],
            ]);
        }

        // Set default payment method
        $tenant->updateDefaultPaymentMethod($paymentMethod);

        // Create subscription
        $priceId = $plan->stripePriceId($interval);

        $subscription = $tenant->newSubscription('default', $priceId)
            ->create($paymentMethod, [
                'metadata' => [
                    'tenant_id' => $tenant->id,
                    'plan' => $plan->slug,
                ],
            ]);

        // Update plan for tenant
        $tenant->update(['plan_id' => $plan->id]);

        return $subscription;
    }
}

6. Subscription Controller

// app/Http/Controllers/Billing/SubscriptionController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Billing;

use App\Domain\Billing\Actions\CreateSubscriptionAction;
use App\Domain\Billing\Models\Plan;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class SubscriptionController extends Controller
{
    public function index()
    {
        $tenant = current_tenant();
        $plans = Plan::where('is_active', true)
            ->orderBy('sort_order')
            ->get();

        return view('billing.plans', [
            'plans' => $plans,
            'currentPlan' => $tenant->plan,
            'subscription' => $tenant->subscription('default'),
        ]);
    }

    public function subscribe(
        Request $request,
        CreateSubscriptionAction $action,
    ) {
        $validated = $request->validate([
            'plan' => 'required|exists:plans,slug',
            'payment_method' => 'required|string',
            'interval' => 'required|in:monthly,yearly',
        ]);

        $tenant = current_tenant();
        $plan = Plan::where('slug', $validated['plan'])->firstOrFail();

        try {
            $action->execute(
                tenant: $tenant,
                plan: $plan,
                paymentMethod: $validated['payment_method'],
                interval: $validated['interval'],
            );

            return redirect()->route('billing.index')
                ->with('success', "Subscribed to {$plan->name}!");
        } catch (\Exception $e) {
            return back()->withErrors([
                'payment' => 'Payment failed: ' . $e->getMessage(),
            ]);
        }
    }

    public function changePlan(Request $request)
    {
        $validated = $request->validate([
            'plan' => 'required|exists:plans,slug',
            'interval' => 'required|in:monthly,yearly',
        ]);

        $tenant = current_tenant();
        $plan = Plan::where('slug', $validated['plan'])->firstOrFail();
        $subscription = $tenant->subscription('default');

        if (! $subscription) {
            return back()->withErrors(['subscription' => 'No subscription found.']);
        }

        $priceId = $plan->stripePriceId($validated['interval']);

        // Swap plan (prorate by default)
        $subscription->swap($priceId);

        $tenant->update(['plan_id' => $plan->id]);

        return redirect()->route('billing.index')
            ->with('success', "Switched to {$plan->name}!");
    }

    public function cancel(Request $request)
    {
        $tenant = current_tenant();
        $subscription = $tenant->subscription('default');

        if ($subscription && ! $subscription->cancelled()) {
            // Cancel at end of billing period
            $subscription->cancel();

            return redirect()->route('billing.index')
                ->with('success', 'Subscription will be cancelled at the end of the billing period.');
        }

        return back()->withErrors(['subscription' => 'No subscription to cancel.']);
    }

    public function resume(Request $request)
    {
        $tenant = current_tenant();
        $subscription = $tenant->subscription('default');

        if ($subscription && $subscription->cancelled() && $subscription->onGracePeriod()) {
            $subscription->resume();

            return redirect()->route('billing.index')
                ->with('success', 'Subscription has been reactivated!');
        }

        return back()->withErrors(['subscription' => 'Cannot resume subscription.']);
    }

    /**
     * Billing portal for customers to manage payment methods.
     */
    public function portal(Request $request)
    {
        $tenant = current_tenant();

        return $tenant->redirectToBillingPortal(
            route('billing.index')
        );
    }
}

7. Billing Routes

// routes/web.php - add to tenant group
Route::middleware(['auth', 'tenant'])->prefix('billing')->name('billing.')->group(function () {
    Route::get('/', [SubscriptionController::class, 'index'])->name('index');
    Route::post('/subscribe', [SubscriptionController::class, 'subscribe'])->name('subscribe');
    Route::put('/change-plan', [SubscriptionController::class, 'changePlan'])->name('change-plan');
    Route::post('/cancel', [SubscriptionController::class, 'cancel'])->name('cancel');
    Route::post('/resume', [SubscriptionController::class, 'resume'])->name('resume');
    Route::get('/portal', [SubscriptionController::class, 'portal'])->name('portal');
});

8. Stripe Webhook Handler

Handling events from Stripe — this is the most critical part of billing:

// app/Http/Controllers/Billing/StripeWebhookController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Billing;

use App\Domain\Tenant\Models\Tenant;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Http\Controllers\WebhookController;

class StripeWebhookController extends WebhookController
{
    /**
     * When a subscription is deleted (grace period ended).
     */
    public function handleCustomerSubscriptionDeleted(array $payload): void
    {
        parent::handleCustomerSubscriptionDeleted($payload);

        $stripeId = $payload['data']['object']['customer'];
        $tenant = Tenant::where('stripe_id', $stripeId)->first();

        if ($tenant) {
            $tenant->update([
                'plan_id' => null,
                'status' => 'suspended',
            ]);

            Log::info('Tenant subscription deleted', [
                'tenant_id' => $tenant->id,
                'stripe_id' => $stripeId,
            ]);

            $tenant->owner->notify(new SubscriptionCancelledNotification($tenant));
        }
    }

    /**
     * When a payment fails.
     */
    public function handleInvoicePaymentFailed(array $payload): void
    {
        $stripeId = $payload['data']['object']['customer'];
        $tenant = Tenant::where('stripe_id', $stripeId)->first();

        if ($tenant) {
            Log::warning('Payment failed for tenant', [
                'tenant_id' => $tenant->id,
                'invoice_id' => $payload['data']['object']['id'],
            ]);

            $tenant->owner->notify(new PaymentFailedNotification(
                $tenant,
                $payload['data']['object']['amount_due'] / 100,
            ));
        }
    }

    /**
     * When a payment succeeds.
     */
    public function handleInvoicePaymentSucceeded(array $payload): void
    {
        $stripeId = $payload['data']['object']['customer'];
        $tenant = Tenant::where('stripe_id', $stripeId)->first();

        if ($tenant && $tenant->status === 'suspended') {
            $tenant->update(['status' => 'active']);

            Log::info('Tenant payment succeeded, reactivated', [
                'tenant_id' => $tenant->id,
            ]);
        }
    }
}

Register Webhook Route

// routes/web.php
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handleWebhook'])
    ->name('cashier.webhook');

Note: The webhook route must be excluded from CSRF verification:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'stripe/webhook',
    ]);
})

9. Blade View: Pricing Page

{{-- resources/views/billing/plans.blade.php --}}
<div class="max-w-5xl mx-auto py-12">
    <h2 class="text-3xl font-bold text-center mb-8">Choose the Right Plan</h2>

    <div class="flex justify-center mb-8">
        <div x-data="{ interval: 'monthly' }" class="inline-flex rounded-lg border p-1">
            <button
                @click="interval = 'monthly'"
                :class="interval === 'monthly' ? 'bg-blue-600 text-white' : ''"
                class="px-4 py-2 rounded-md text-sm font-medium"
            >
                Monthly
            </button>
            <button
                @click="interval = 'yearly'"
                :class="interval === 'yearly' ? 'bg-blue-600 text-white' : ''"
                class="px-4 py-2 rounded-md text-sm font-medium"
            >
                Yearly <span class="text-green-500 text-xs">-17%</span>
            </button>
        </div>
    </div>

    <div class="grid md:grid-cols-3 gap-8">
        @foreach($plans as $plan)
        <div class="border rounded-xl p-6 {{ $currentPlan?->id === $plan->id ? 'ring-2 ring-blue-500' : '' }}">
            <h3 class="text-xl font-bold">{{ $plan->name }}</h3>

            <div class="mt-4">
                <span x-show="interval === 'monthly'" class="text-4xl font-bold">
                    ${{ number_format($plan->monthly_price, 0) }}
                </span>
                <span x-show="interval === 'yearly'" class="text-4xl font-bold">
                    ${{ number_format($plan->yearly_price / 12, 0) }}
                </span>
                <span class="text-gray-500">/month</span>
            </div>

            <ul class="mt-6 space-y-3">
                <li class="flex items-center gap-2">
                    <x-icon name="check" class="text-green-500" />
                    {{ $plan->limits['projects'] === -1 ? 'Unlimited' : $plan->limits['projects'] }} projects
                </li>
                <li class="flex items-center gap-2">
                    <x-icon name="check" class="text-green-500" />
                    {{ $plan->limits['members'] === -1 ? 'Unlimited' : $plan->limits['members'] }} team members
                </li>
                <li class="flex items-center gap-2">
                    <x-icon name="check" class="text-green-500" />
                    {{ number_format($plan->limits['storage_mb'] / 1000, 0) }}GB storage
                </li>
                @if($plan->limits['api_access'])
                <li class="flex items-center gap-2">
                    <x-icon name="check" class="text-green-500" />
                    API Access
                </li>
                @endif
            </ul>

            <div class="mt-8">
                @if($currentPlan?->id === $plan->id)
                    <button disabled class="w-full py-2 rounded-lg bg-gray-200 text-gray-500">
                        Current Plan
                    </button>
                @else
                    <button
                        @click="$dispatch('select-plan', { plan: '{{ $plan->slug }}', interval: interval })"
                        class="w-full py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700"
                    >
                        {{ $currentPlan ? 'Switch Plan' : 'Get Started' }}
                    </button>
                @endif
            </div>
        </div>
        @endforeach
    </div>
</div>

10. Trial Period

Handle the trial when a new user creates a tenant:

// app/Domain/Billing/Actions/StartTrialAction.php
<?php

declare(strict_types=1);

namespace App\Domain\Billing\Actions;

use App\Domain\Billing\Models\Plan;
use App\Domain\Tenant\Models\Tenant;

class StartTrialAction
{
    public function execute(Tenant $tenant, int $trialDays = 14): void
    {
        // Assign the Starter plan during trial
        $starterPlan = Plan::where('slug', 'starter')->first();

        $tenant->update([
            'plan_id' => $starterPlan?->id,
            'trial_ends_at' => now()->addDays($trialDays),
        ]);
    }
}

Trial Check Middleware

// app/Http/Middleware/CheckTrialOrSubscription.php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class CheckTrialOrSubscription
{
    public function handle(Request $request, Closure $next): Response
    {
        $tenant = current_tenant();

        if (! $tenant) {
            return redirect()->route('onboarding');
        }

        $hasActiveSubscription = $tenant->subscribed('default')
            && ! $tenant->subscription('default')->cancelled();

        $onTrial = $tenant->onTrial();

        if (! $hasActiveSubscription && ! $onTrial) {
            return redirect()->route('billing.index')
                ->with('warning', 'Your trial has expired. Please choose a plan to continue.');
        }

        // Warn when trial is about to expire
        if ($onTrial && $tenant->trial_ends_at->diffInDays(now()) <= 3) {
            session()->flash('trial_warning',
                "Your trial expires in {$tenant->trial_ends_at->diffInDays(now())} days. Choose a plan to avoid interruption."
            );
        }

        return $next($request);
    }
}

11. Invoice & Receipt

// app/Http/Controllers/Billing/InvoiceController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Billing;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class InvoiceController extends Controller
{
    public function index()
    {
        $tenant = current_tenant();
        $invoices = $tenant->invoices();

        return view('billing.invoices', ['invoices' => $invoices]);
    }

    public function download(Request $request, string $invoiceId)
    {
        $tenant = current_tenant();

        return $tenant->downloadInvoice($invoiceId, [
            'vendor' => 'SaaS App',
            'product' => $tenant->plan?->name ?? 'Subscription',
            'street' => 'Your Address',
            'location' => 'City, Country',
            'phone' => '+1 xxx xxx xxxx',
        ]);
    }
}

12. Testing Billing

// tests/Feature/BillingTest.php
<?php

use App\Domain\Billing\Actions\CreateSubscriptionAction;
use App\Domain\Billing\Models\Plan;
use App\Domain\Tenant\Actions\CreateTenantAction;
use App\Domain\User\Models\User;
use App\Providers\TenantServiceProvider;

beforeEach(function () {
    $this->user = User::factory()->create();
    $this->tenant = (new CreateTenantAction())->execute($this->user, 'Test Co');
    TenantServiceProvider::setTenant($this->tenant);

    // Seed plans
    $this->seed(\Database\Seeders\PlanSeeder::class);
});

test('plans page shows all active plans', function () {
    $this->actingAs($this->user)
        ->get('/billing')
        ->assertOk()
        ->assertSee('Starter')
        ->assertSee('Professional')
        ->assertSee('Business');
});

test('tenant starts with 14-day trial', function () {
    expect($this->tenant->onTrial())->toBeTrue()
        ->and($this->tenant->trial_ends_at->diffInDays(now()))->toBe(14);
});

test('expired trial redirects to billing', function () {
    $this->tenant->update(['trial_ends_at' => now()->subDay()]);

    $this->actingAs($this->user)
        ->get('/dashboard')
        ->assertRedirect(route('billing.index'));
});

test('can cancel subscription', function () {
    // Assuming subscription already exists
    $this->actingAs($this->user)
        ->post('/billing/cancel')
        ->assertRedirect();
});

Summary

In Part 3, we built:

  • 3 pricing plans with monthly/yearly billing
  • Subscription management: subscribe, change plan, cancel, resume
  • Stripe Webhook handler for payment events
  • Trial period with 14-day free trial
  • Invoice download for customers
  • Billing portal redirect
  • Middleware to check trial/subscription status
  • Responsive pricing page

Key takeaways:

  • Tenant is the billable entity, not User
  • Always handle webhooks properly — that's how Stripe notifies about payment events
  • Trials improve user conversion
  • Allow graceful cancellation (end of billing period)

Next up: Team Management & Permissions — inviting members, roles, permissions, and team collaboration.

Comments