Building SaaS with Laravel (Part 3): Billing with Stripe Cashier
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.