Advanced Laravel
Building a SaaS Application
Building a SaaS Application
Software as a Service (SaaS) applications require special architectural considerations including subscription management, multi-tenancy, billing, and scalable infrastructure. Laravel provides excellent tools through Laravel Cashier and other packages to build robust SaaS platforms.
SaaS Architecture Fundamentals
A typical SaaS application architecture includes:
- Multi-tenancy: Isolating customer data and resources
- Subscription billing: Recurring payments and plan management
- Feature gating: Controlling access based on subscription tier
- Usage tracking: Monitoring API calls, storage, and resources
- Analytics: Tracking user behavior and business metrics
Note: Choose between database-level multi-tenancy (separate databases per tenant) or application-level multi-tenancy (shared database with tenant_id) based on your isolation and scaling requirements.
Installing Laravel Cashier
Laravel Cashier provides an expressive interface to Stripe's subscription billing services:
composer require laravel/cashier
php artisan migrate
# Publish configuration
php artisan vendor:publish --tag="cashier-migrations"
User Model Setup for Billing
Update your User model to include Cashier's Billable trait:
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Cashier\Billable;
class User extends Authenticatable
{
use Billable;
/**
* Get the user's subscription plan name
*/
public function planName(): string
{
if ($this->subscribed('default')) {
return $this->subscription('default')->stripe_price;
}
return 'Free';
}
/**
* Check if user can access a feature
*/
public function canAccessFeature(string $feature): bool
{
return match ($this->planName()) {
'price_starter' => in_array($feature, ['basic_reports', 'email_support']),
'price_professional' => in_array($feature, ['basic_reports', 'advanced_reports', 'email_support', 'api_access']),
'price_enterprise' => true, // All features
default => in_array($feature, ['basic_reports']), // Free tier
};
}
/**
* Check if user has exceeded usage limits
*/
public function hasExceededUsageLimit(string $resource): bool
{
$limits = [
'Free' => ['api_calls' => 100, 'storage_mb' => 100],
'price_starter' => ['api_calls' => 10000, 'storage_mb' => 1000],
'price_professional' => ['api_calls' => 100000, 'storage_mb' => 10000],
'price_enterprise' => ['api_calls' => PHP_INT_MAX, 'storage_mb' => PHP_INT_MAX],
];
$plan = $this->planName();
$currentUsage = $this->getUsage($resource);
return $currentUsage >= ($limits[$plan][$resource] ?? 0);
}
protected function getUsage(string $resource): int
{
// Implementation to track usage from database or cache
return cache()->get("user.{$this->id}.usage.{$resource}", 0);
}
}
Creating Subscription Plans
Define your subscription plans in a configuration file or database:
<?php
// config/subscriptions.php
return [
'plans' => [
[
'id' => 'starter',
'name' => 'Starter',
'price_id' => env('STRIPE_STARTER_PRICE_ID'),
'price' => 29,
'currency' => 'usd',
'interval' => 'month',
'trial_days' => 14,
'features' => [
'Up to 10,000 API calls/month',
'1 GB storage',
'Basic reports',
'Email support',
],
'limits' => [
'api_calls' => 10000,
'storage_mb' => 1000,
'team_members' => 3,
],
],
[
'id' => 'professional',
'name' => 'Professional',
'price_id' => env('STRIPE_PROFESSIONAL_PRICE_ID'),
'price' => 99,
'currency' => 'usd',
'interval' => 'month',
'trial_days' => 14,
'features' => [
'Up to 100,000 API calls/month',
'10 GB storage',
'Advanced reports',
'Priority email support',
'API access',
],
'limits' => [
'api_calls' => 100000,
'storage_mb' => 10000,
'team_members' => 10,
],
],
[
'id' => 'enterprise',
'name' => 'Enterprise',
'price_id' => env('STRIPE_ENTERPRISE_PRICE_ID'),
'price' => 299,
'currency' => 'usd',
'interval' => 'month',
'trial_days' => 30,
'features' => [
'Unlimited API calls',
'Unlimited storage',
'Custom reports',
'24/7 phone support',
'Advanced API access',
'Custom integrations',
],
'limits' => [
'api_calls' => PHP_INT_MAX,
'storage_mb' => PHP_INT_MAX,
'team_members' => PHP_INT_MAX,
],
],
],
];
Tip: Store Stripe price IDs in environment variables for different environments (development, staging, production). This allows you to test with test mode prices before going live.
Subscription Controller
Create a controller to handle subscription operations:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class SubscriptionController extends Controller
{
/**
* Display subscription plans
*/
public function index()
{
$plans = config('subscriptions.plans');
$user = Auth::user();
return view('subscriptions.index', [
'plans' => $plans,
'currentPlan' => $user->planName(),
'onTrial' => $user->onTrial(),
'subscribed' => $user->subscribed('default'),
]);
}
/**
* Create a new subscription
*/
public function store(Request $request)
{
$request->validate([
'plan' => 'required|in:starter,professional,enterprise',
'payment_method' => 'required|string',
]);
$user = $request->user();
$plan = collect(config('subscriptions.plans'))
->firstWhere('id', $request->plan);
try {
// Create subscription with trial period
$subscription = $user->newSubscription('default', $plan['price_id'])
->trialDays($plan['trial_days'])
->create($request->payment_method);
// Log subscription event
activity()
->causedBy($user)
->log("Subscribed to {$plan['name']} plan");
return redirect()
->route('subscriptions.success')
->with('success', "Successfully subscribed to {$plan['name']} plan!");
} catch (\Exception $e) {
return back()
->withErrors(['subscription' => 'Failed to create subscription: ' . $e->getMessage()]);
}
}
/**
* Update subscription plan
*/
public function update(Request $request)
{
$request->validate([
'plan' => 'required|in:starter,professional,enterprise',
]);
$user = $request->user();
$plan = collect(config('subscriptions.plans'))
->firstWhere('id', $request->plan);
if (!$user->subscribed('default')) {
return back()->withErrors(['subscription' => 'No active subscription found.']);
}
try {
// Swap to new plan (prorated by default)
$user->subscription('default')
->swap($plan['price_id']);
activity()
->causedBy($user)
->log("Changed subscription to {$plan['name']} plan");
return back()->with('success', "Successfully changed to {$plan['name']} plan!");
} catch (\Exception $e) {
return back()->withErrors(['subscription' => 'Failed to update subscription.']);
}
}
/**
* Cancel subscription
*/
public function destroy(Request $request)
{
$user = $request->user();
if (!$user->subscribed('default')) {
return back()->withErrors(['subscription' => 'No active subscription found.']);
}
// Cancel at period end (don't immediately cancel)
$user->subscription('default')->cancel();
activity()
->causedBy($user)
->log('Cancelled subscription');
return back()->with('success', 'Your subscription has been cancelled. You can continue using it until the end of the billing period.');
}
/**
* Resume cancelled subscription
*/
public function resume(Request $request)
{
$user = $request->user();
if (!$user->subscription('default')->cancelled()) {
return back()->withErrors(['subscription' => 'Subscription is not cancelled.']);
}
$user->subscription('default')->resume();
return back()->with('success', 'Your subscription has been resumed!');
}
}
Webhook Handler
Handle Stripe webhooks for subscription events:
<?php
namespace App\Http\Controllers;
use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;
use Illuminate\Support\Facades\Notification;
use App\Notifications\SubscriptionRenewed;
use App\Notifications\PaymentFailed;
class WebhookController extends CashierController
{
/**
* Handle subscription renewed event
*/
public function handleCustomerSubscriptionUpdated(array $payload)
{
$user = $this->getUserByStripeId($payload['data']['object']['customer']);
if ($user) {
// Send renewal notification
$user->notify(new SubscriptionRenewed($payload['data']['object']));
// Log event
activity()
->causedBy($user)
->withProperties($payload)
->log('Subscription renewed');
}
return $this->successMethod();
}
/**
* Handle failed payment
*/
public function handleInvoicePaymentFailed(array $payload)
{
$user = $this->getUserByStripeId($payload['data']['object']['customer']);
if ($user) {
// Notify user of failed payment
$user->notify(new PaymentFailed($payload['data']['object']));
// Log failure
activity()
->causedBy($user)
->withProperties($payload)
->log('Payment failed');
// Optionally downgrade to free tier after grace period
if ($this->exceedsGracePeriod($user)) {
$user->subscription('default')->cancelNow();
}
}
return $this->successMethod();
}
/**
* Check if payment failure exceeds grace period
*/
protected function exceedsGracePeriod($user): bool
{
$subscription = $user->subscription('default');
$gracePeriodDays = 7;
return $subscription->past_due_at
&& now()->diffInDays($subscription->past_due_at) > $gracePeriodDays;
}
}
Warning: Always verify webhook signatures in production to ensure requests are actually coming from Stripe. Laravel Cashier handles this automatically if you configure the webhook secret in your .env file.
Feature Gate Middleware
Create middleware to restrict access based on subscription features:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class CheckSubscriptionFeature
{
/**
* Handle an incoming request
*/
public function handle(Request $request, Closure $next, string $feature)
{
$user = $request->user();
if (!$user || !$user->canAccessFeature($feature)) {
return redirect()
->route('subscriptions.index')
->with('error', 'This feature requires a higher subscription plan.');
}
return $next($request);
}
}
// Usage in routes/web.php
Route::middleware(['auth', 'subscription.feature:advanced_reports'])->group(function () {
Route::get('/reports/advanced', [ReportController::class, 'advanced']);
});
Exercise 1: Create a subscription system with three plans: Free, Pro ($19/month), and Business ($49/month). Implement feature gates for "advanced_analytics" (Pro+) and "api_access" (Business only). Add a usage tracking system that limits API calls per month based on the plan.
Exercise 2: Build a billing portal that allows users to view their current subscription, upgrade/downgrade plans, update payment methods, view invoices, and cancel subscriptions. Include a trial period countdown and "Resume Subscription" option for cancelled subscriptions.
Exercise 3: Implement a webhook handler that sends email notifications for: subscription renewal, payment failure, subscription cancellation, and trial ending in 3 days. Add an admin dashboard that shows MRR (Monthly Recurring Revenue), churn rate, and active subscriptions by plan.
Summary
In this lesson, you learned:
- SaaS architecture fundamentals and multi-tenancy patterns
- Setting up Laravel Cashier for subscription billing
- Creating and managing subscription plans with trials
- Handling webhooks for subscription events
- Implementing feature gates and usage limits