DEV Community

Cover image for Multi-Tenant SaaS with Laravel: Automatic Data Isolation Using Global Scopes (No External Packages)
Francesco Caglioti
Francesco Caglioti

Posted on • Originally published at fcaglioti.cc

Multi-Tenant SaaS with Laravel: Automatic Data Isolation Using Global Scopes (No External Packages)

Building a B2B SaaS platform for transport companies, I faced a critical architectural decision: separate database per tenant or shared database with logical isolation?

I chose shared database. Here's why and how I implemented bulletproof data isolation with pure Laravel.

Why Shared Database

For a B2B SaaS, database evolution is critical:

Pros:

  • Atomic migrations (add a column once, not 500 times)
  • Simplified backup/restore
  • Lower infrastructure costs

Cons:

  • Logical isolation (not physical)
  • Noisy neighbor risk

If I ever reach 500 tenants, managing 500 separate migrations per feature would be a full-time job. Shared DB wins.

The Architecture

USER REQUEST
     │
     ▼
MIDDLEWARE (EnsureTenantAccess)
     │ 1. Verify authentication
     │ 2. Extract tenant ID
     │ 3. Store in TenantContext (singleton)
     ▼
ELOQUENT MODELS (with BelongsToTenant trait)
     │ 4. Apply GlobalScope automatically
     ▼
DATABASE QUERY
     SELECT * FROM table WHERE tenant_id = [X]
Enter fullscreen mode Exit fullscreen mode

TenantContext: The Source of Truth

class TenantContext
{
    private ?string $tenantId = null;

    public function set(string $id): void
    {
        $this->tenantId = $id;
    }

    public function id(): string
    {
        return $this->tenantId;
    }

    public function isSet(): bool
    {
        return $this->tenantId !== null;
    }
}
Enter fullscreen mode Exit fullscreen mode

Singleton. One instance per request. Zero ambiguity.

BelongsToTenant Trait

This is where the magic happens:

trait BelongsToTenant
{
    public static function bootBelongsToTenant(): void
    {
        // READ: Auto-filter all queries
        static::addGlobalScope('tenant', function (Builder $query) {
            $context = app(TenantContext::class);
            if ($context->isSet()) {
                $query->where('tenant_id', $context->id());
            }
        });

        // WRITE: Auto-assign tenant_id on create
        static::creating(function (Model $model) {
            $context = app(TenantContext::class);
            if ($context->isSet() && !$model->tenant_id) {
                $model->tenant_id = $context->id();
            }
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Every tenant-specific model (Customers, Vehicles, Transports) uses this trait. No manual tenant_id assignment. No forgetfulness bugs.

Testing Cross-Tenant Isolation

public function test_cross_tenant_isolation(): void
{
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();

    Product::factory()->for($tenantA)->create(['name' => 'Item A']);
    Product::factory()->for($tenantB)->create(['name' => 'Item B']);

    $response = $this->actingAs($this->userInTenant($tenantA))
        ->getJson('/api/v1/products');

    $response->assertOk();
    $response->assertJsonCount(1, 'data');
    $response->assertJsonPath('data.0.name', 'Item A');
}
Enter fullscreen mode Exit fullscreen mode

Without this test, I'm just hoping the GlobalScope works. Hope is not a strategy.

Super Admin Impersonation

Support needs to see what customers see. Solution:

$tenantId = $user->isSuperAdmin()
    ? session('impersonate_tenant_id')
    : $user->tenant_id;
Enter fullscreen mode Exit fullscreen mode

Super Admin has no fixed tenant_id. Via dashboard, they select a tenant to impersonate. Session stores the ID. Middleware populates TenantContext. Same exact views as the customer.

Read-Only Mode

if ($tenant->is_read_only && $request->isMethodSafe() === false) {
    abort(403, 'Account in read-only mode');
}
Enter fullscreen mode Exit fullscreen mode

Use cases:

  • Payment suspension
  • Maintenance windows
  • Investigation locks

All centralized in middleware. Zero controller pollution.

Anti-Patterns Learned

  1. Manual tenant_id assignment: You WILL forget. Use the trait.
  2. Unique indexes without tenant_id: Always composite (tenant_id + unique_field)
  3. Incremental IDs for tenants: Use UUIDs. Prevents ID guessing attacks.

Would I Use a Package?

Spatie has spatie/laravel-multitenancy. It's solid.

But building my own gave me:

  • Full control over edge cases
  • Super admin impersonation (not trivial with packages)
  • Deep understanding of the isolation boundaries

If you're learning or need custom flows, build it yourself. If you need speed and standard patterns, use a package.

Conclusion

Multi-tenancy with Laravel is about trust: trust in your automation, distrust in human memory.

Global Scopes + TenantContext + comprehensive tests = sleep well at night.

Top comments (0)