LaravelSaaSArchitecturePHP

Building a Multi-Tenant SaaS App with Laravel: Practical Guide

··15 min read

Multi-tenancy means a single application instance serves multiple customers (tenants), with each tenant's data isolated from others. There are two main approaches: separate databases per tenant (clean isolation, expensive) or a single shared database with a tenant_id column on every table (cost-effective, requires disciplined scoping). This guide covers the single-database approach used in most production SaaS platforms.

Architecture Overview

  • Every tenant-scoped table has a tenant_id foreign key
  • A global scope automatically filters queries by the current tenant
  • The current tenant is resolved from the request (subdomain, header, or JWT claim)
  • Super-admin routes bypass tenant scoping entirely

Step 1: Tenant Model and Migration

php
// database/migrations/create_tenants_table.php
Schema::create('tenants', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique(); // used as subdomain
    $table->string('domain')->nullable()->unique();
    $table->json('settings')->nullable();
    $table->enum('status', ['active', 'suspended', 'trial'])->default('trial');
    $table->timestamp('trial_ends_at')->nullable();
    $table->timestamps();
});

Step 2: TenantScope Global Scope

The global scope automatically adds a WHERE tenant_id = ? to every Eloquent query on tenant-scoped models. This is the core safety mechanism — without it, tenants can see each other's data.

php
<?php

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (app()->bound('current_tenant')) {
            $builder->where($model->getTable() . '.tenant_id', app('current_tenant')->id);
        }
    }
}

Step 3: BelongsToTenant Trait

php
<?php

namespace App\Models\Concerns;

use App\Models\Scopes\TenantScope;

trait BelongsToTenant
{
    public static function bootBelongsToTenant(): void
    {
        static::addGlobalScope(new TenantScope());

        static::creating(function ($model) {
            if (app()->bound('current_tenant') && empty($model->tenant_id)) {
                $model->tenant_id = app('current_tenant')->id;
            }
        });
    }

    public function tenant()
    {
        return $this->belongsTo(\App\Models\Tenant::class);
    }
}

// Usage on any model:
class Project extends Model
{
    use BelongsToTenant;

    protected $fillable = ['tenant_id', 'name', 'description'];
}

Step 4: Tenant Resolution Middleware

php
<?php

namespace App\Http\Middleware;

use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ResolveTenant
{
    public function handle(Request $request, Closure $next): Response
    {
        // Resolve by subdomain: tenant.yoursaas.com
        $host   = $request->getHost();
        $parts  = explode('.', $host);
        $slug   = count($parts) >= 3 ? $parts[0] : null;

        // Or resolve by header for API clients:
        // $slug = $request->header('X-Tenant');

        if (!$slug) {
            return response()->json(['message' => 'Tenant not found'], 404);
        }

        $tenant = Tenant::where('slug', $slug)->where('status', 'active')->first();

        if (!$tenant) {
            return response()->json(['message' => 'Tenant not found or suspended'], 404);
        }

        app()->instance('current_tenant', $tenant);

        return $next($request);
    }
}

Step 5: Register Middleware

php
// bootstrap/app.php (Laravel 11)
->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'tenant' => \App\Http\Middleware\ResolveTenant::class,
    ]);
})

// routes/api.php
Route::middleware(['tenant', 'auth:sanctum'])->group(function () {
    Route::apiResource('projects', ProjectController::class);
    Route::apiResource('users', TenantUserController::class);
});

// Super-admin routes bypass tenant scoping
Route::middleware(['auth:sanctum', 'role:superadmin'])->prefix('admin')->group(function () {
    Route::get('tenants', [AdminTenantController::class, 'index']);
});

Step 6: Bypassing Scope for Admin Queries

php
// When you genuinely need cross-tenant data (admin reports, billing):
$allProjects = Project::withoutGlobalScope(TenantScope::class)->get();

// Or use a dedicated admin query scope:
$tenantProjects = Project::withoutGlobalScope(TenantScope::class)
    ->where('tenant_id', $specificTenantId)
    ->get();

Critical Security Checklist

  • Every table that holds tenant data must have a tenant_id column with a foreign key constraint
  • Always use the BelongsToTenant trait on every tenant-scoped model — never forget it
  • Write tests that attempt cross-tenant access and assert they return 0 results
  • Be careful with raw DB queries — they bypass Eloquent scopes entirely
  • File storage: prefix S3 keys with tenant_id/ to prevent cross-tenant file access
  • Cache keys: prefix with tenant ID to prevent cache poisoning across tenants

Note: For serious multi-tenant SaaS, consider the stancl/tenancy package which automates much of this. But understanding the manual implementation first helps you debug issues and customise when the package doesn't fit.

Pratik Yewale

Pratik Yewale

Assistant Manager – Software at Qing Aamby City Developers Corporations Limited. Previously Backend Developer at Neuromonk Infotech building scalable APIs, ERP systems, and booking platforms. Published researcher in cricket analytics.

View Portfolio