Building SaaS with Laravel (Part 2): Multi-Tenancy & Data Isolation

· 12 min read

In Part 1, we set up the project and database schema. Now it's time to dive into the most important part of SaaS: multi-tenancy — ensuring data between organizations is completely isolated.

Multi-Tenancy Strategy

There are 3 common approaches:

Strategy Isolation Complexity Scale
Shared Database, Shared Schema Low Low Easy
Shared Database, Separate Schema Medium Medium Medium
Separate Database High High Hard

We choose Shared Database, Shared Schema because:

  • Simplest to implement
  • Resource-efficient for the early stage
  • Laravel Eloquent supports it well via Global Scopes
  • Can migrate to separate databases later if needed

1. Enhancing the Tenant Scope

In Part 1, we created a basic TenantScope. Let's now enhance it:

// app/Domain/Tenant/Scopes/TenantScope.php
<?php

declare(strict_types=1);

namespace App\Domain\Tenant\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
    {
        $tenant = app('current_tenant');

        if ($tenant) {
            $builder->where(
                $model->qualifyColumn('tenant_id'),
                $tenant->id
            );
        }
    }

    public function extend(Builder $builder): void
    {
        // Allow querying across tenants when needed (admin)
        $builder->macro('withoutTenantScope', function (Builder $builder) {
            return $builder->withoutGlobalScope(self::class);
        });

        // Query for a specific tenant
        $builder->macro('forTenant', function (Builder $builder, int $tenantId) {
            return $builder->withoutGlobalScope(self::class)
                ->where('tenant_id', $tenantId);
        });
    }
}

2. Improving the BelongsToTenant Trait

// app/Domain/Tenant/Traits/BelongsToTenant.php
<?php

declare(strict_types=1);

namespace App\Domain\Tenant\Traits;

use App\Domain\Tenant\Models\Tenant;
use App\Domain\Tenant\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

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

        static::creating(function ($model) {
            if (! app()->runningInConsole() || app()->runningUnitTests()) {
                $tenant = app('current_tenant');
                if ($tenant && ! $model->tenant_id) {
                    $model->tenant_id = $tenant->id;
                }
            }
        });
    }

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

    /**
     * Ensure the model belongs to the current tenant.
     */
    public function belongsToCurrentTenant(): bool
    {
        $tenant = app('current_tenant');

        return $tenant && $this->tenant_id === $tenant->id;
    }
}

3. Applying to Models

Project Model

// app/Domain/Project/Models/Project.php
<?php

declare(strict_types=1);

namespace App\Domain\Project\Models;

use App\Domain\Team\Models\Team;
use App\Domain\Tenant\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;

class Project extends Model
{
    use BelongsToTenant, SoftDeletes;

    protected $fillable = [
        'tenant_id',
        'team_id',
        'name',
        'description',
        'status',
        'due_date',
    ];

    protected $casts = [
        'due_date' => 'date',
    ];

    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }

    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }

    public function isActive(): bool
    {
        return $this->status === 'active';
    }

    public function completionPercentage(): float
    {
        $total = $this->tasks()->count();

        if ($total === 0) {
            return 0;
        }

        $done = $this->tasks()->where('status', 'done')->count();

        return round(($done / $total) * 100, 1);
    }
}

Task Model

// app/Domain/Project/Models/Task.php
<?php

declare(strict_types=1);

namespace App\Domain\Project\Models;

use App\Domain\Tenant\Traits\BelongsToTenant;
use App\Domain\User\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Task extends Model
{
    use BelongsToTenant;

    protected $fillable = [
        'project_id',
        'tenant_id',
        'assigned_to',
        'created_by',
        'title',
        'description',
        'status',
        'priority',
        'due_date',
        'position',
    ];

    protected $casts = [
        'due_date' => 'date',
        'position' => 'integer',
    ];

    public function project(): BelongsTo
    {
        return $this->belongsTo(Project::class);
    }

    public function assignee(): BelongsTo
    {
        return $this->belongsTo(User::class, 'assigned_to');
    }

    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by');
    }

    public function isDone(): bool
    {
        return $this->status === 'done';
    }

    public function isOverdue(): bool
    {
        return $this->due_date !== null
            && $this->due_date->isPast()
            && ! $this->isDone();
    }
}

Team Model

// app/Domain/Team/Models/Team.php
<?php

declare(strict_types=1);

namespace App\Domain\Team\Models;

use App\Domain\Project\Models\Project;
use App\Domain\Tenant\Traits\BelongsToTenant;
use App\Domain\User\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Team extends Model
{
    use BelongsToTenant;

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

    public function members(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'team_user')
            ->withPivot('role')
            ->withTimestamps();
    }

    public function projects(): HasMany
    {
        return $this->hasMany(Project::class);
    }
}

4. Subdomain Routing

To give each tenant their own URL like acme.saas-app.com:

Domain Configuration

// config/app.php - add to the config array
'tenant_domain' => env('TENANT_DOMAIN', 'saas-app.com'),

Subdomain Resolution Middleware

// app/Http/Middleware/ResolveSubdomainTenant.php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

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

class ResolveSubdomainTenant
{
    public function handle(Request $request, Closure $next): Response
    {
        $host = $request->getHost();
        $baseDomain = config('app.tenant_domain');

        // Skip if on the main domain
        if ($host === $baseDomain || $host === 'www.' . $baseDomain) {
            return $next($request);
        }

        // Extract the subdomain
        $subdomain = str_replace('.' . $baseDomain, '', $host);

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

        if (! $tenant) {
            abort(404, 'Organization not found.');
        }

        TenantServiceProvider::setTenant($tenant);

        // Set on request for use in other middleware
        $request->attributes->set('tenant', $tenant);

        return $next($request);
    }
}

Routes with Subdomain

// routes/web.php
<?php

use Illuminate\Support\Facades\Route;

// Main domain routes: registration, landing page
Route::domain(config('app.tenant_domain'))->group(function () {
    Route::get('/', fn () => view('welcome'));
    Route::get('/register', [RegisterController::class, 'show'])->name('register');
    Route::post('/register', [RegisterController::class, 'store']);
    Route::get('/login', [LoginController::class, 'show'])->name('login');
    Route::post('/login', [LoginController::class, 'store']);
});

// Tenant subdomain routes
Route::domain('{tenant}.' . config('app.tenant_domain'))
    ->middleware(['web', 'auth', 'resolve.subdomain'])
    ->group(function () {
        Route::get('/dashboard', [DashboardController::class, 'index'])
            ->name('tenant.dashboard');

        Route::resource('projects', ProjectController::class);
        Route::resource('projects.tasks', TaskController::class)->shallow();
    });

5. Tenant Context Helper

Create a helper for easy access to the current tenant:

// app/Domain/Tenant/helpers.php
<?php

use App\Domain\Tenant\Models\Tenant;

if (! function_exists('current_tenant')) {
    function current_tenant(): ?Tenant
    {
        return app('current_tenant');
    }
}

if (! function_exists('tenant_id')) {
    function tenant_id(): ?int
    {
        return current_tenant()?->id;
    }
}

Register in composer.json:

{
    "autoload": {
        "files": [
            "app/Domain/Tenant/helpers.php"
        ]
    }
}

Run composer dump-autoload after adding.

6. Tenant Switching

Allow users who belong to multiple tenants to switch between them:

// app/Http/Controllers/TenantSwitchController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Domain\Tenant\Models\Tenant;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class TenantSwitchController extends Controller
{
    public function __invoke(Request $request, Tenant $tenant): RedirectResponse
    {
        $user = $request->user();

        // Check that the user has access to this tenant
        if (! $user->tenants()->where('tenant_id', $tenant->id)->exists()) {
            abort(403);
        }

        $user->switchTenant($tenant);

        // Redirect to the new tenant's dashboard
        $domain = $tenant->slug . '.' . config('app.tenant_domain');

        return redirect()->to(
            $request->getScheme() . '://' . $domain . '/dashboard'
        );
    }
}

7. Protecting Against Cross-Tenant Access

One of the most dangerous security vulnerabilities in SaaS is cross-tenant data leakage. We use multiple layers of protection:

Layer 1: Global Scope (already in place)

Global scope automatically filters by tenant in every query.

Layer 2: Route Model Binding Check

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    // Automatically verify tenant ownership during route model binding
    Route::bind('project', function ($value) {
        $project = Project::findOrFail($value);

        if (! $project->belongsToCurrentTenant()) {
            abort(404);
        }

        return $project;
    });

    Route::bind('task', function ($value) {
        $task = Task::findOrFail($value);

        if (! $task->belongsToCurrentTenant()) {
            abort(404);
        }

        return $task;
    });
}

Layer 3: Policy Authorization

// app/Domain/Project/Policies/ProjectPolicy.php
<?php

declare(strict_types=1);

namespace App\Domain\Project\Policies;

use App\Domain\Project\Models\Project;
use App\Domain\User\Models\User;

class ProjectPolicy
{
    public function viewAny(User $user): bool
    {
        // Tenant scope already handles filtering
        return true;
    }

    public function view(User $user, Project $project): bool
    {
        return $project->tenant_id === $user->current_tenant_id;
    }

    public function create(User $user): bool
    {
        return $user->current_tenant_id !== null;
    }

    public function update(User $user, Project $project): bool
    {
        return $project->tenant_id === $user->current_tenant_id;
    }

    public function delete(User $user, Project $project): bool
    {
        return $project->tenant_id === $user->current_tenant_id;
    }
}

Layer 4: Middleware Double Check

// app/Http/Middleware/VerifyTenantAccess.php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyTenantAccess
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();
        $tenant = current_tenant();

        if (! $tenant || ! $user) {
            abort(403);
        }

        // Verify the user actually belongs to the current tenant
        $isMember = $user->tenants()
            ->where('tenant_id', $tenant->id)
            ->exists();

        if (! $isMember) {
            abort(403, 'You do not have access to this organization.');
        }

        return $next($request);
    }
}

8. Tenant-scoped Caching

Cache must be separated between tenants:

// app/Domain/Tenant/Services/TenantCacheService.php
<?php

declare(strict_types=1);

namespace App\Domain\Tenant\Services;

use Illuminate\Support\Facades\Cache;

class TenantCacheService
{
    public static function key(string $key): string
    {
        $tenantId = tenant_id();

        return $tenantId ? "tenant_{$tenantId}:{$key}" : $key;
    }

    public static function get(string $key, mixed $default = null): mixed
    {
        return Cache::get(self::key($key), $default);
    }

    public static function put(string $key, mixed $value, int $ttl = 3600): bool
    {
        return Cache::put(self::key($key), $value, $ttl);
    }

    public static function forget(string $key): bool
    {
        return Cache::forget(self::key($key));
    }

    /**
     * Flush all cache for a specific tenant.
     */
    public static function flush(int $tenantId): void
    {
        Cache::tags(["tenant_{$tenantId}"])->flush();
    }
}

9. Testing Multi-Tenancy

Testing is the most important part — ensuring data isolation works correctly:

// tests/Feature/MultiTenancyTest.php
<?php

use App\Domain\Project\Models\Project;
use App\Domain\Tenant\Actions\CreateTenantAction;
use App\Domain\User\Models\User;
use App\Providers\TenantServiceProvider;

beforeEach(function () {
    $this->action = new CreateTenantAction();

    // Create 2 users and 2 tenants
    $this->user1 = User::factory()->create();
    $this->user2 = User::factory()->create();

    $this->tenant1 = $this->action->execute($this->user1, 'Company A');
    $this->tenant2 = $this->action->execute($this->user2, 'Company B');
});

test('tenant scope isolates data', function () {
    // Create project for tenant 1
    TenantServiceProvider::setTenant($this->tenant1);
    $project1 = Project::create([
        'name' => 'Project A',
        'tenant_id' => $this->tenant1->id,
    ]);

    // Create project for tenant 2
    TenantServiceProvider::setTenant($this->tenant2);
    $project2 = Project::create([
        'name' => 'Project B',
        'tenant_id' => $this->tenant2->id,
    ]);

    // Tenant 1 only sees their own project
    TenantServiceProvider::setTenant($this->tenant1);
    $projects = Project::all();
    expect($projects)->toHaveCount(1)
        ->and($projects->first()->name)->toBe('Project A');

    // Tenant 2 only sees their own project
    TenantServiceProvider::setTenant($this->tenant2);
    $projects = Project::all();
    expect($projects)->toHaveCount(1)
        ->and($projects->first()->name)->toBe('Project B');
});

test('cannot access other tenant project via API', function () {
    // Create project for tenant 1
    TenantServiceProvider::setTenant($this->tenant1);
    $project = Project::create([
        'name' => 'Secret Project',
        'tenant_id' => $this->tenant1->id,
    ]);

    // User 2 tries to access tenant 1's project
    TenantServiceProvider::setTenant($this->tenant2);

    $this->actingAs($this->user2)
        ->get("/projects/{$project->id}")
        ->assertNotFound();
});

test('auto-assigns tenant_id on create', function () {
    TenantServiceProvider::setTenant($this->tenant1);

    $project = Project::create(['name' => 'Auto Tenant Project']);

    expect($project->tenant_id)->toBe($this->tenant1->id);
});

test('withoutTenantScope returns all data', function () {
    TenantServiceProvider::setTenant($this->tenant1);
    Project::create([
        'name' => 'Project A',
        'tenant_id' => $this->tenant1->id,
    ]);

    TenantServiceProvider::setTenant($this->tenant2);
    Project::create([
        'name' => 'Project B',
        'tenant_id' => $this->tenant2->id,
    ]);

    // Admin query across all tenants
    $all = Project::withoutTenantScope()->get();
    expect($all)->toHaveCount(2);
});

test('user can switch between tenants', function () {
    // Add user1 to tenant2
    $this->tenant2->users()->attach($this->user1->id, ['role' => 'member']);

    $this->user1->switchTenant($this->tenant2);

    expect($this->user1->fresh()->current_tenant_id)
        ->toBe($this->tenant2->id);
});

test('user cannot switch to tenant they do not belong to', function () {
    // User2 tries to switch to tenant1 without being invited
    expect(fn () => $this->user2->switchTenant($this->tenant1))
        ->toThrow(\DomainException::class);
});

10. Debugging Tips

Log Tenant Context

Add tenant info to every log entry:

// app/Providers/AppServiceProvider.php
use Illuminate\Log\LogManager;
use Illuminate\Support\Facades\Log;

public function boot(): void
{
    Log::shareContext([
        'tenant_id' => fn () => tenant_id(),
    ]);
}

Query Log for Development

// Add to AppServiceProvider::boot() in local environment
if (app()->isLocal()) {
    \DB::listen(function ($query) {
        if (str_contains($query->sql, 'tenant_id')) {
            logger()->debug('Tenant Query', [
                'sql' => $query->sql,
                'bindings' => $query->bindings,
                'time' => $query->time . 'ms',
            ]);
        }
    });
}

Summary

In Part 2, we built:

  • Global Scope that automatically filters data by tenant
  • Subdomain routing for each tenant
  • 4 layers of protection against cross-tenant access
  • Tenant switching for users belonging to multiple organizations
  • Cache isolation per tenant
  • Comprehensive test suite for multi-tenancy

The most important takeaway: multi-tenancy must be protected at multiple layers. Never rely on a single layer alone.

Next up: Billing with Stripe Cashier — integrating subscription payments, managing plans, and handling webhooks.

Comments