NextGenBeing
Listen to Article
Loading...Building a Modern SaaS Application with Laravel - Part 2: Core Implementation & Design Patterns
Read Time: ~35 minutes | Difficulty: Intermediate to Advanced | Part 2 of 3
Table of Contents
- Introduction & Architecture Overview
- Multi-Tenancy Implementation Patterns
- Domain-Driven Design Structure
- Authentication & Authorization System
- Feature Flag System Implementation
- Event-Driven Architecture
- API Design & Rate Limiting
- Background Job Processing
- Database Optimization Patterns
- Common Pitfalls & Solutions
- Performance Benchmarks
- What's Next in Part 3
Introduction & Architecture Overview
In Part 1, we set up our foundation. Now we're building the core of a production SaaS application that can scale to thousands of tenants. This isn't a toy app—this is the architecture pattern used by successful SaaS companies like Intercom, GitHub, and Basecamp.
What We're Building: A multi-tenant project management SaaS with:
- Team-based isolation (each team is a tenant)
- Role-based permissions (Owner, Admin, Member, Guest)
- Feature flags for gradual rollouts
- Event-driven notifications
- API-first architecture with rate limiting
- Background job processing for heavy operations
Architecture Pattern:
┌─────────────────────────────────────────────────────┐
│ Load Balancer │
└─────────────────────┬───────────────────────────────┘
│
┌─────────────┴─────────────┐
│ │
┌───────▼────────┐ ┌────────▼───────┐
│ Web Servers │ │ API Servers │
│ (Stateless) │ │ (Stateless) │
└───────┬────────┘ └────────┬───────┘
│ │
└─────────────┬─────────────┘
│
┌─────────────▼─────────────┐
│ Application Layer │
│ (Domain Logic + DDD) │
└─────────────┬─────────────┘
│
┌─────────────┼─────────────┐
│ │ │
┌───────▼────┐ ┌────▼─────┐ ┌───▼──────┐
│ Database │ │ Redis │ │ Queue │
│ (Primary) │ │ Cache │ │ Workers │
└────────────┘ └──────────┘ └──────────┘
Multi-Tenancy Implementation Patterns
The Problem: Tenant Isolation
You have three main approaches to multi-tenancy:
- Separate Databases - One DB per tenant (Slack's approach for large customers)
- Separate Schemas - One DB, multiple schemas (Salesforce pattern)
- Shared Database - One DB, tenant_id column (most Laravel SaaS apps)
We're implementing approach #3 with hybrid capability for approach #1. Why? Cost-effective for small tenants, can isolate large customers later.
Database Schema Design
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* The multi-tenancy approach here uses a tenant_id column
* with composite indexes for optimal query performance.
*
* CRITICAL: Every tenant-scoped table MUST have tenant_id
* as the first column in composite indexes.
*/
public function up(): void
{
// Core tenant table
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique()->index();
$table->string('name');
$table->string('slug')->unique();
$table->string('domain')->nullable()->unique();
// Subscription info
$table->string('plan')->default('free'); // free, starter, professional, enterprise
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('subscription_ends_at')->nullable();
// Limits based on plan
$table->json('limits')->nullable(); // {users: 5, projects: 10, storage_gb: 1}
$table->json('features')->nullable(); // Feature flags per tenant
// Database isolation for enterprise
$table->string('database_name')->nullable(); // For separate DB approach
$table->string('database_host')->nullable();
$table->softDeletes();
$table->timestamps();
// Indexes for common queries
$table->index(['plan', 'created_at']);
});
// Tenant users (pivot between users and tenants)
Schema::create('tenant_user', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('role')->default('member'); // owner, admin, member, guest
$table->json('permissions')->nullable(); // Custom permissions override
$table->timestamp('invited_at')->nullable();
$table->timestamp('joined_at')->nullable();
$table->timestamps();
// Prevent duplicate memberships
$table->unique(['tenant_id', 'user_id']);
// Query optimization
$table->index(['user_id', 'role']);
});
// Example tenant-scoped resource
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->uuid('uuid')->unique();
$table->string('name');
$table->text('description')->nullable();
$table->string('status')->default('active'); // active, archived, deleted
$table->foreignId('created_by')->constrained('users');
$table->softDeletes();
$table->timestamps();
// CRITICAL: tenant_id must be first in composite indexes
$table->index(['tenant_id', 'status', 'created_at']);
$table->index(['tenant_id', 'created_by']);
});
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->uuid('uuid')->unique();
$table->string('title');
$table->text('description')->nullable();
$table->string('status')->default('todo'); // todo, in_progress, done
$table->integer('priority')->default(0);
$table->foreignId('assigned_to')->nullable()->constrained('users');
$table->foreignId('created_by')->constrained('users');
$table->timestamp('due_at')->nullable();
$table->softDeletes();
$table->timestamps();
// Performance indexes
$table->index(['tenant_id', 'project_id', 'status']);
$table->index(['tenant_id', 'assigned_to', 'status']);
$table->index(['tenant_id', 'due_at']);
});
}
public function down(): void
{
Schema::dropIfExists('tasks');
Schema::dropIfExists('projects');
Schema::dropIfExists('tenant_user');
Schema::dropIfExists('tenants');
}
};
Global Tenant Scope Implementation
The Problem: Developers forget to add where('tenant_id', ...) and leak data between tenants. This is a GDPR nightmare waiting to happen.
The Solution: Global scopes that automatically filter queries. Here's the production-ready implementation:
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Facades\Auth;
/**
* Automatically scope all queries to the current tenant.
*
* SECURITY CRITICAL: This prevents data leakage between tenants.
*
* Usage: Add TenantScoped trait to models that need tenant isolation.
*/
class TenantScope implements Scope
{
/**
* Apply the scope to a given Eloquent query builder.
*/
public function apply(Builder $builder, Model $model): void
{
// Get tenant from current context
$tenantId = $this->getCurrentTenantId();
if ($tenantId) {
$builder->where($model->getQualifiedTenantColumn(), $tenantId);
}
}
/**
* Extend the query builder with withoutTenantScope() method
* for admin operations that need cross-tenant access.
*/
public function extend(Builder $builder): void
{
$builder->macro('withoutTenantScope', function (Builder $builder) {
return $builder->withoutGlobalScope($this);
});
// Allow querying specific tenant even if different from current
$builder->macro('forTenant', function (Builder $builder, $tenantId) {
return $builder->withoutGlobalScope($this)
->where($builder->getModel()->getQualifiedTenantColumn(), $tenantId);
});
}
/**
* Get the current tenant ID from the request context.
*
* This uses middleware-set tenant context, not just auth user,
* because API requests might use different tenant context.
*/
protected function getCurrentTenantId(): ?int
{
// Try to get from request context first (set by middleware)
if ($tenant = app('current_tenant')) {
return $tenant->id;
}
// Fallback to authenticated user's current tenant
if (Auth::check() && Auth::user()->current_tenant_id) {
return Auth::user()->current_tenant_id;
}
return null;
}
}
<?php
namespace App\Models\Concerns;
use App\Models\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Model;
/**
* Trait for models that need tenant isolation.
*
* IMPORTANT: Only add this to models that should be tenant-scoped.
* Do NOT add to User model or other cross-tenant resources.
*/
trait TenantScoped
{
/**
* Boot the tenant scoped trait for a model.
*/
protected static function bootTenantScoped(): void
{
static::addGlobalScope(new TenantScope);
// Automatically set tenant_id on model creation
static::creating(function (Model $model) {
if (!$model->getAttribute('tenant_id')) {
$tenantId = app('current_tenant')?->id
?? auth()->user()?->current_tenant_id;
if (!$tenantId) {
throw new \RuntimeException(
'Cannot create tenant-scoped model without tenant context. ' .
'Set tenant via TenantContext middleware or manually set tenant_id.'
);
}
$model->setAttribute('tenant_id', $tenantId);
}
});
}
/**
* Get the name of the tenant column.
*/
public function getTenantColumn(): string
{
return 'tenant_id';
}
/**
* Get the fully qualified tenant column.
*/
public function getQualifiedTenantColumn(): string
{
return $this->qualifyColumn($this->getTenantColumn());
}
}
Tenant Context Middleware
<?php
namespace App\Http\Middleware;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Set the current tenant context for the request.
*
* This middleware resolves the tenant from:
* 1. Subdomain (team1.yourapp.com)
* 2. Custom domain (customdomain.com)
* 3. API token tenant context
* 4. User's current tenant selection
*
* IMPORTANT: This must run AFTER authentication middleware.
*/
class SetTenantContext
{
public function handle(Request $request, Closure $next): Response
{
$tenant = $this->resolveTenant($request);
if (!$tenant) {
// For API requests, return 400
if ($request->expectsJson()) {
return response()->json([
'error' => 'Tenant context required',
'message' => 'Unable to determine tenant. Use subdomain, custom domain, or X-Tenant-ID header.'
], 400);
}
// For web requests, redirect to tenant selection
return redirect()->route('tenant.select');
}
// Set tenant in application container
app()->instance('current_tenant', $tenant);
// Share with views
view()->share('currentTenant', $tenant);
// Verify user has access to this tenant
if (Auth::check() && !$this->userHasAccessToTenant(Auth::user(), $tenant)) {
abort(403, 'You do not have access to this workspace.');
}
return $next($request);
}
/**
* Resolve tenant from various sources.
*/
protected function resolveTenant(Request $request): ?Tenant
{
// 1. Try custom domain first (highest priority)
$host = $request->getHost();
if ($tenant = Tenant::where('domain', $host)->first()) {
return $tenant;
}
// 2. Try subdomain
$subdomain = $this->getSubdomain($request);
if ($subdomain && $subdomain !== 'www') {
if ($tenant = Tenant::where('slug', $subdomain)->first()) {
return $tenant;
}
}
// 3. Try X-Tenant-ID header (for API requests)
if ($tenantId = $request->header('X-Tenant-ID')) {
if ($tenant = Tenant::where('uuid', $tenantId)->first()) {
return $tenant;
}
}
// 4. Try authenticated user's current tenant
if (Auth::check() && Auth::user()->current_tenant_id) {
return Tenant::find(Auth::user()->current_tenant_id);
}
return null;
}
/**
* Extract subdomain from request.
*/
protected function getSubdomain(Request $request): ?string
{
$host = $request->getHost();
$appDomain = config('app.domain'); // e.g., 'yourapp.com'
if (!str_ends_with($host, $appDomain)) {
return null;
}
$subdomain = str_replace('.' . $appDomain, '', $host);
return $subdomain !== $appDomain ? $subdomain : null;
}
/**
* Check if user has access to tenant.
*/
protected function userHasAccessToTenant($user, Tenant $tenant): bool
{
return $user->tenants()->where('tenant_id', $tenant->id)->exists();
}
}
Model Implementation
<?php
namespace App\Models;
use App\Models\Concerns\TenantScoped;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Project extends Model
{
use HasFactory, SoftDeletes, TenantScoped;
protected $fillable = [
'name',
'description',
'status',
'created_by',
];
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
/**
* Boot the model.
*/
protected static function boot(): void
{
parent::boot();
// Auto-generate UUID
static::creating(function (Project $project) {
if (!$project->uuid) {
$project->uuid = Str::uuid();
}
});
}
/**
* Relationships
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function tasks(): HasMany
{
return $this->hasMany(Task::class);
}
/**
* Scopes
*/
public function scopeActive($query)
{
return $query->where('status', 'active');
}
public function scopeArchived($query)
{
return $query->where('status', 'archived');
}
/**
* Check if user can access this project.
*
* SECURITY: Always verify tenant membership before checking project access.
*/
public function userCanAccess(User $user): bool
{
// First check tenant membership
if (!$user->tenants()->where('tenant_id', $this->tenant_id)->exists()) {
return false;
}
// Add project-specific access logic here
// For now, any tenant member can access projects
return true;
}
}
Domain-Driven Design Structure
Why DDD for SaaS?
Traditional Laravel architecture (Model-Controller-View) breaks down as your SaaS grows. At 50k+ lines of code, you need:
- Domain separation: Billing logic shouldn't mix with project management
- Testability: Business rules isolated from framework
- Team scalability: Different teams can work on different domains
Our Directory Structure:
app/
├── Domain/
│ ├── Tenant/
│ │ ├── Actions/
│ │ │ ├── CreateTenantAction.php
│ │ │ ├── SwitchTenantAction.php
│ │ │ └── UpgradeTenantPlanAction.php
│ │ ├── DataTransferObjects/
│ │ │ └── TenantData.php
│ │ ├── Events/
│ │ │ ├── TenantCreated.php
│ │ │ └── TenantPlanUpgraded.php
│ │ ├── Exceptions/
│ │ │ └── TenantLimitExceededException.php
│ │ └── Models/
│ │ └── Tenant.php
│ ├── Project/
│ │ ├── Actions/
│ │ │ ├── CreateProjectAction.php
│ │ │ └── ArchiveProjectAction.php
│ │ ├── Models/
│ │ │ └── Project.php
│ │ └── Policies/
│ │ └── ProjectPolicy.php
│ └── Billing/
│ ├── Actions/
│ │ ├── CreateSubscriptionAction.php
│ │ └── HandleWebhookAction.php
│ └── Services/
│ └── StripeService.php
├── Http/
│ ├── Controllers/
│ │ ├── Api/
│ │ │ ├── V1/
│ │ │ │ ├── ProjectController.php
│ │ │ │ └── TaskController.php
│ │ └── Web/
│ │ ├── DashboardController.php
│ │ └── TenantController.php
│ ├── Middleware/
│ └── Requests/
│ ├── CreateProjectRequest.php
│ └── UpdateTaskRequest.php
└── Support/
├── Helpers/
└── Traits/
Action Pattern Implementation
Actions are single-purpose classes that contain business logic. Think of them as "use cases" or "commands".
<?php
namespace App\Domain\Tenant\Actions;
use App\Domain\Tenant\DataTransferObjects\TenantData;
use App\Domain\Tenant\Events\TenantCreated;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Create a new tenant with proper setup and defaults.
*
* This action handles:
* - Tenant creation with validation
* - Owner assignment
* - Default settings initialization
* - Event dispatching for onboarding
*
* PATTERN: Single Responsibility - one action, one purpose
*
* Usage:
* $tenant = app(CreateTenantAction::class)->execute($data, $owner);
*/
class CreateTenantAction
{
/**
* Execute the action.
*
* @throws \Exception if tenant creation fails
*/
public function execute(TenantData $data, User $owner): Tenant
{
return DB::transaction(function () use ($data, $owner) {
// 1. Create tenant
$tenant = Tenant::create([
'uuid' => Str::uuid(),
'name' => $data->name,
'slug' => $data->slug ?? Str::slug($data->name),
'plan' => $data->plan ?? 'free',
'trial_ends_at' => now()->addDays(14),
'limits' => $this->getDefaultLimits($data->plan ?? 'free'),
'features' => $this->getDefaultFeatures($data->plan ?? 'free'),
]);
// 2. Attach owner
$tenant->users()->attach($owner->id, [
'role' => 'owner',
'joined_at' => now(),
]);
// 3. Update user's current tenant
$owner->update(['current_tenant_id' => $tenant->id]);
// 4. Create default resources (optional)
$this->createDefaultResources($tenant, $owner);
// 5. Dispatch events for onboarding, notifications, etc.
event(new TenantCreated($tenant, $owner));
return $tenant->fresh();
});
}
/**
* Get default limits based on plan.
*/
protected function getDefaultLimits(string $plan): array
{
return match($plan) {
'free' => [
'users' => 3,
'projects' => 5,
'storage_gb' => 1,
'api_calls_per_minute' => 60,
],
'starter' => [
'users' => 10,
'projects' => 25,
'storage_gb' => 10,
'api_calls_per_minute' => 300,
],
'professional' => [
'users' => 50,
'projects' => 100,
'storage_gb' => 100,
'api_calls_per_minute' => 1000,
],
'enterprise' => [
'users' => -1, // unlimited
'projects' => -1,
'storage_gb' => 1000,
'api_calls_per_minute' => 5000,
],
default => [],
};
}
/**
* Get default feature flags based on plan.
*/
protected function getDefaultFeatures(string $plan): array
{
$features = [
'projects' => true,
'tasks' => true,
'comments' => true,
];
if (in_array($plan, ['professional', 'enterprise'])) {
$features['advanced_reporting'] = true;
$features['custom_fields'] = true;
$features['api_access'] = true;
}
if ($plan === 'enterprise') {
$features['sso'] = true;
$features['audit_logs'] = true;
$features['custom_domain'] = true;
$features['priority_support'] = true;
}
return $features;
}
/**
* Create default resources for new tenant.
*/
protected function createDefaultResources(Tenant $tenant, User $owner): void
{
// Set tenant context temporarily
$previousTenant = app('current_tenant');
app()->instance('current_tenant', $tenant);
try {
// Create welcome project
$project = \App\Models\Project::create([
'tenant_id' => $tenant->id,
'name' => 'Welcome Project',
'description' => 'Get started with your first project!',
'created_by' => $owner->id,
]);
// Create sample task
\App\Models\Task::create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'title' => 'Explore your workspace',
'description' => 'Take a look around and customize your settings.',
'created_by' => $owner->id,
]);
} finally {
// Restore previous tenant context
app()->instance('current_tenant', $previousTenant);
}
}
}
Data Transfer Objects (DTOs)
DTOs ensure type safety and validate data before it reaches your business logic.
<?php
namespace App\Domain\Tenant\DataTransferObjects;
use Illuminate\Support\Str;
/**
* Immutable data object for tenant creation/updates.
*
* WHY USE DTOs:
* 1. Type safety - no more array['typo'] bugs
* 2. Validation in one place
* 3. IDE autocompletion
* 4. Easy refactoring
*
* PATTERN: This uses Spatie's laravel-data package approach
* but you can also use simple readonly classes in PHP 8.2+
*/
readonly class TenantData
{
public function __construct(
public string $name,
public ?string $slug = null,
public ?string $plan = null,
public ?string $domain = null,
) {
$this->validate();
}
/**
* Create from array (typically from request).
*/
public static function fromArray(array $data): self
{
return new self(
name: $data['name'],
slug: $data['slug'] ?? null,
plan: $data['plan'] ?? 'free',
domain: $data['domain'] ?? null,
);
}
/**
* Create from request.
*/
public static function fromRequest(\Illuminate\Http\Request $request): self
{
return self::fromArray($request->validated());
}
/**
* Validate the data.
*/
protected function validate(): void
{
if (empty(trim($this->name))) {
throw new \InvalidArgumentException('Tenant name is required.');
}
if (strlen($this->name) > 100) {
throw new \InvalidArgumentException('Tenant name must be 100 characters or less.');
}
if ($this->slug && !preg_match('/^[a-z0-9-]+$/', $this->slug)) {
throw new \InvalidArgumentException('Slug must contain only lowercase letters, numbers, and hyphens.');
}
if ($this->plan && !in_array($this->plan, ['free', 'starter', 'professional', 'enterprise'])) {
throw new \InvalidArgumentException('Invalid plan specified.');
}
}
/**
* Convert to array.
*/
public function toArray(): array
{
return [
'name' => $this->name,
'slug' => $this->slug ?? Str::slug($this->name),
'plan' => $this->plan ?? 'free',
'domain' => $this->domain,
];
}
}
Authentication & Authorization System
Multi-Tenant Permission System
The Challenge: Traditional role-based access control (RBAC) doesn't work well for multi-tenant SaaS because:
- Users have different roles in different tenants
- Permissions need to be checked per-tenant, not globally
- Some actions require both tenant-level AND resource-level permissions
Our Approach: Hybrid RBAC + Ownership model
<?php
namespace App\Domain\Authorization;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
/**
* Authorization service for multi-tenant permission checks.
*
* PERFORMANCE: Caches permission checks to avoid N+1 queries.
* Cache is invalidated when roles change.
*
* Usage:
* if (app(AuthorizationService::class)->can($user, 'create', 'projects', $tenant)) {
* // User can create projects in this tenant
* }
*/
class AuthorizationService
{
/**
* Role hierarchy (higher number = more permissions)
*/
protected const ROLE_HIERARCHY = [
'guest' => 1,
'member' => 2,
'admin' => 3,
'owner' => 4,
];
/**
* Permission matrix: [role => [ability => [resources]]]
*/
protected const PERMISSIONS = [
'guest' => [
'view' => ['projects', 'tasks'],
],
'member' => [
'view' => ['projects', 'tasks', 'comments'],
'create' => ['tasks', 'comments'],
'update' => ['tasks', 'comments'], // Only own resources
'delete' => ['comments'], // Only own comments
],
'admin' => [
'view' => ['projects', 'tasks', 'comments', 'members'],
'create' => ['projects', 'tasks', 'comments'],
'update' => ['projects', 'tasks', 'comments'],
'delete' => ['projects', 'tasks', 'comments'],
'invite' => ['members'],
],
'owner' => [
'view' => ['*'],
'create' => ['*'],
'update' => ['*'],
'delete' => ['*'],
'invite' => ['*'],
'manage' => ['billing', 'settings', 'members'],
],
];
/**
* Check if user can perform ability on resource in tenant.
*
* @param User $user
* @param string $ability (view, create, update, delete, etc.)
* @param string $resource (projects, tasks, etc.)
* @param Tenant $tenant
* @param mixed $resourceInstance (optional, for ownership check)
* @return bool
*/
public function can(
User $user,
string $ability,
string $resource,
Tenant $tenant,
mixed $resourceInstance = null
): bool {
// Get user's role in this tenant
$role = $this->getUserRole($user, $tenant);
if (!$role) {
return false;
}
// Check permission matrix
if (!$this->roleHasPermission($role, $ability, $resource)) {
return false;
}
// If checking specific resource, verify ownership for members
if ($resourceInstance && $role === 'member') {
return $this->userOwnsResource($user, $resourceInstance, $ability);
}
return true;
}
/**
* Get user's role in tenant (with caching).
*/
public function getUserRole(User $user, Tenant $tenant): ?string
{
$cacheKey = "user.{$user->id}.tenant.{$tenant->id}.role";
return Cache::remember($cacheKey, 3600, function () use ($user, $tenant) {
$membership = $user->tenants()
->where('tenant_id', $tenant->id)
->first();
return $membership?->pivot->role;
});
}
/**
* Check if role has permission for ability on resource.
*/
protected function roleHasPermission(string $role, string $ability, string $resource): bool
{
$permissions = self::PERMISSIONS[$role] ?? [];
// Check wildcard permission (owner has all)
if (isset($permissions[$ability]) && in_array('*', $permissions[$ability])) {
return true;
}
// Check specific resource permission
return isset($permissions[$ability]) && in_array($resource, $permissions[$ability]);
}
/**
* Check if user owns the resource (for member-level checks).
*/
protected function userOwnsResource(User $user, mixed $resource, string $ability): bool
{
// Members can only update/delete their own resources
if (in_array($ability, ['update', 'delete'])) {
if (method_exists($resource, 'isOwnedBy')) {
return $resource->isOwnedBy($user);
}
// Fallback to created_by column
if (isset($resource->created_by)) {
return $resource->created_by === $user->id;
}
}
return false;
}
/**
* Check if user has at least the specified role.
*/
public function hasRole(User $user, string $minimumRole, Tenant $tenant): bool
{
$userRole = $this->getUserRole($user, $tenant);
if (!$userRole) {
return false;
}
return self::ROLE_HIERARCHY[$userRole] >= self::ROLE_HIERARCHY[$minimumRole];
}
/**
* Invalidate user's permission cache.
*/
public function invalidateCache(User $user, Tenant $tenant): void
{
Cache::forget("user.{$user->id}.tenant.{$tenant->id}.role");
}
}
Policy Implementation
<?php
namespace App\Policies;
use App\Domain\Authorization\AuthorizationService;
use App\Models\Project;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
/**
* Policy for Project authorization.
*
* IMPORTANT: Laravel's policy registration automatically maps
* these methods to Gate::allows() checks.
*
* Convention: viewAny, view, create, update, delete, restore, forceDelete
*/
class ProjectPolicy
{
use HandlesAuthorization;
public function __construct(
protected AuthorizationService $authService
) {}
/**
* Determine if user can view any projects.
*/
public function viewAny(User $user): bool
{
$tenant = app('current_tenant');
if (!$tenant) {
return false;
}
return $this->authService->can($user, 'view', 'projects', $tenant);
}
/**
* Determine if user can view the project.
*/
public function view(User $user, Project $project): bool
{
$tenant = app('current_tenant');
if (!$tenant || $project->tenant_id !== $tenant->id) {
return false;
}
return $this->authService->can($user, 'view', 'projects', $tenant);
}
/**
* Determine if user can create projects.
*/
public function create(User $user): bool
{
$tenant = app('current_tenant');
if (!$tenant) {
return false;
}
// Check permission
if (!$this->authService->can($user, 'create', 'projects', $tenant)) {
return false;
}
// Check tenant limit
$currentCount = Project::where('tenant_id', $tenant->id)->count();
$limit = $tenant->limits['projects'] ?? 0;
if ($limit !== -1 && $currentCount >= $limit) {
return false;
}
return true;
}
/**
* Determine if user can update the project.
*/
public function update(User $user, Project $project): bool
{
$tenant = app('current_tenant');
if (!$tenant || $project->tenant_id !== $tenant->id) {
return false;
}
return $this->authService->can($user, 'update', 'projects', $tenant, $project);
}
/**
* Determine if user can delete the project.
*/
public function delete(User $user, Project $project): bool
{
$tenant = app('current_tenant');
if (!$tenant || $project->tenant_id !== $tenant->id) {
return false;
}
// Only admins and owners can delete projects
return $this->authService->hasRole($user, 'admin', $tenant);
}
}
Middleware for API Authentication
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* API Token Authentication Middleware.
*
* Supports two token types:
* 1. User API tokens (personal access tokens)
* 2. Tenant API tokens (for server-to-server)
*
* Token format: {tenant_uuid}.{token_hash}
*
* Usage in routes: ->middleware('auth:api')
*/
class ApiTokenAuth
{
public function handle(Request $request, Closure $next): Response
{
$token = $request->bearerToken();
if (!$token) {
return response()->json([
'error' => 'Unauthorized',
'message' => 'API token required. Use Authorization: Bearer {token}'
], 401);
}
// Parse token format: {tenant_uuid}.{token_hash}
$parts = explode('.', $token, 2);
if (count($parts) !== 2) {
return response()->json([
'error' => 'Invalid token format',
'message' => 'Token must be in format: {tenant_id}.{token}'
], 401);
}
[$tenantUuid, $tokenHash] = $parts;
// Find tenant
$tenant = \App\Models\Tenant::where('uuid', $tenantUuid)->first();
if (!$tenant) {
return response()->json([
'error' => 'Invalid tenant'
], 401);
}
// Set tenant context
app()->instance('current_tenant', $tenant);
// Verify token
$apiToken = \App\Models\ApiToken::where('tenant_id', $tenant->id)
->where('token', hash('sha256', $tokenHash))
->where('expires_at', '>', now())
->first();
if (!$apiToken) {
return response()->json([
'error' => 'Invalid or expired token'
], 401);
}
// Update last used timestamp
$apiToken->update(['last_used_at' => now()]);
// Set authenticated user if token has associated user
if ($apiToken->user_id) {
auth()->setUser($apiToken->user);
}
// Store token in request for rate limiting
$request->merge(['_api_token' => $apiToken]);
return $next($request);
}
}
Feature Flag System Implementation
Why Feature Flags in SaaS?
- Gradual rollouts: Test new features with 5% of tenants
- A/B testing: New UI for half your users
- Plan-based features: Enterprise-only capabilities
- Kill switches: Instantly disable problematic features
Database Schema
<?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('features', function (Blueprint $table) {
$table->id();
$table->string('key')->unique(); // e.g., 'advanced_reporting'
$table->string('name');
$table->text('description')->nullable();
$table->boolean('enabled_globally')->default(false);
$table->json('rollout_percentage')->nullable(); // {percentage: 50, seed: 'hash'}
$table->json('allowed_plans')->nullable(); // ['professional', 'enterprise']
$table->json('metadata')->nullable();
$table->timestamps();
});
Schema::create('tenant_features', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('feature_id')->constrained()->cascadeOnDelete();
$table->boolean('enabled')->default(true);
$table->json('config')->nullable(); // Feature-specific configuration
$table->timestamp('enabled_at')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'feature_id']);
});
}
public function down(): void
{
Schema::dropIfExists('tenant_features');
Schema::dropIfExists('features');
}
};
Feature Flag Service
<?php
namespace App\Domain\Features;
use App\Models\Feature;
use App\Models\Tenant;
use Illuminate\Support\Facades\Cache;
/**
* Feature flag service with multiple strategies.
*
* Supports:
* - Global toggles (all tenants)
* - Per-tenant overrides
* - Gradual rollouts (percentage-based)
* - Plan-based features
*
* PATTERN: This is inspired by LaunchDarkly and Flagsmith.
*
* Performance: Caches feature checks for 5 minutes.
*/
class FeatureFlagService
{
/**
* Check if feature is enabled for tenant.
*
* Resolution order:
* 1. Tenant-specific override
* 2. Gradual rollout percentage
* 3. Plan-based availability
* 4. Global toggle
*/
public function isEnabled(string $featureKey, Tenant $tenant): bool
{
$cacheKey = "feature.{$featureKey}.tenant.{$tenant->id}";
return Cache::remember($cacheKey, 300, function () use ($featureKey, $tenant) {
$feature = Feature::where('key', $featureKey)->first();
if (!$feature) {
// Unknown feature defaults to disabled
return false;
}
// 1. Check tenant-specific override
$tenantFeature = $tenant->features()
->where('feature_id', $feature->id)
->first();
if ($tenantFeature) {
return (bool) $tenantFeature->pivot->enabled;
}
// 2. Check plan-based availability
if ($feature->allowed_plans && !in_array($tenant->plan, $feature->allowed_plans)) {
return false;
}
// 3. Check gradual rollout
if ($feature->rollout_percentage) {
return $this->isInRollout($feature, $tenant);
}
// 4. Check global toggle
return $feature->enabled_globally;
});
}
/**
* Check multiple features at once (batch query).
*/
public function getEnabledFeatures(Tenant $tenant, array $featureKeys): array
{
$enabled = [];
foreach ($featureKeys as $key) {
if ($this->isEnabled($key, $tenant)) {
$enabled[] = $key;
}
}
return $enabled;
}
/**
* Get all enabled features for tenant (for frontend).
*/
public function getAllEnabledForTenant(Tenant $tenant): array
{
$cacheKey = "features.all.tenant.{$tenant->id}";
return Cache::remember($cacheKey, 300, function () use ($tenant) {
$allFeatures = Feature::all();
$enabled = [];
foreach ($allFeatures as $feature) {
if ($this->isEnabled($feature->key, $tenant)) {
$enabled[$feature->key] = [
'name' => $feature->name,
'metadata' => $feature->metadata,
];
}
}
return $enabled;
});
}
/**
* Enable feature for specific tenant.
*/
public function enableForTenant(string $featureKey, Tenant $tenant, ?array $config = null): void
{
$feature = Feature::where('key', $featureKey)->firstOrFail();
$tenant->features()->syncWithoutDetaching([
$feature->id => [
'enabled' => true,
'config' => $config,
'enabled_at' => now(),
]
]);
$this->invalidateCache($featureKey, $tenant);
}
/**
* Disable feature for specific tenant.
*/
public function disableForTenant(string $featureKey, Tenant $tenant): void
{
$feature = Feature::where('key', $featureKey)->firstOrFail();
$tenant->features()->syncWithoutDetaching([
$feature->id => [
'enabled' => false,
]
]);
$this->invalidateCache($featureKey, $tenant);
}
/**
* Check if tenant is in gradual rollout.
*
* Uses consistent hashing so same tenant always gets same result.
*/
protected function isInRollout(Feature $feature, Tenant $tenant): bool
{
$percentage = $feature->rollout_percentage['percentage'] ?? 0;
$seed = $feature->rollout_percentage['seed'] ?? $feature->key;
// Generate consistent hash for this tenant + feature
$hash = hexdec(substr(md5($tenant->id . $seed), 0, 8));
Never Miss an Article
Get our best content delivered to your inbox weekly. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log InRelated Articles
Optimizing Database Performance with Indexing and Caching: What We Learned Scaling to 100M Queries/Day
Apr 18, 2026
Building a Modern SaaS Application with Laravel - Part 1: Architecture, Setup & Foundations
Apr 19, 2026
Building a Modern SaaS Application with Laravel - Part 3: Advanced Features & Configuration
Apr 19, 2026