Building a Modern SaaS Application with Laravel - Part 2: Core Implementation & Design Patterns - NextGenBeing Building a Modern SaaS Application with Laravel - Part 2: Core Implementation & Design Patterns - NextGenBeing
Back to discoveries

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**...

Comprehensive Tutorials 2 min read
NextGenBeing

NextGenBeing

Apr 19, 2026 6 views
Size:
Height:
📖 2 min read 📝 8,928 words 👁 Focus mode: ✨ Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished

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

  1. Introduction & Architecture Overview
  2. Multi-Tenancy Implementation Patterns
  3. Domain-Driven Design Structure
  4. Authentication & Authorization System
  5. Feature Flag System Implementation
  6. Event-Driven Architecture
  7. API Design & Rate Limiting
  8. Background Job Processing
  9. Database Optimization Patterns
  10. Common Pitfalls & Solutions
  11. Performance Benchmarks
  12. 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:

  1. Separate Databases - One DB per tenant (Slack's approach for large customers)
  2. Separate Schemas - One DB, multiple schemas (Salesforce pattern)
  3. 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 In

Related Articles