Multi-Tenant Laravel: Isolation Strategies Without the Regret

How to choose between separate databases, schemas, and a shared schema with tenant scoping in Laravel — plus the places tenant isolation leaks in production and how to test for them.

Multi-Tenant Laravel: Isolation Strategies Without the Regret

Multi-tenancy is the decision you make on day one and live with for years. Get it wrong and you either migrate every customer's data later, or you ship a bug where one tenant sees another tenant's invoice. There is no single right answer — but there is a right process for choosing.

The three models, honestly compared

Database per tenant. Each customer gets their own database. Isolation is physical, backups and restores are per-tenant, noisy neighbours can't touch each other's connection pool. The cost is migration orchestration: every schema change runs across N databases, and reporting across tenants means assembling data yourself.

Schema per tenant. One database, one schema each. Slightly less isolation, slightly simpler provisioning — on PostgreSQL this is a genuinely good middle ground. Still requires looping migrations, and connection/session handling gets fiddly.

Shared schema with a tenant_id. One database, every row tagged. Cheapest to run, simplest to migrate, easiest to build admin and cross-tenant reporting. The entire risk sits in one place: forgetting the scope. It's a good default for the majority of B2B SaaS until a customer contract or a data-residency rule says otherwise.

We start shared-schema by default, and only move a customer to an isolated database when compliance or scale actually demands it — not because it seemed more "enterprise".

Make the scope impossible to forget

A global scope is the usual tool, and it's a fine one:

trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope('tenant', fn (Builder $q) =>
            $q->where($q->getModel()->getTable().'.tenant_id', app(TenantContext::class)->id())
        );

        static::creating(fn ($model) =>
            $model->tenant_id ??= app(TenantContext::class)->id()
        );
    }
}

Then resolve the tenant from the subdomain in middleware, before the route hits a controller:

public function handle(Request $request, Closure $next): Response
{
    $tenant = Tenant::where('domain', $request->getHost())->firstOrFail();
    app(TenantContext::class)->set($tenant);
    return $next($request);
}

The important part is fail-closed behaviour: if no tenant context is set, the scope should throw rather than return everything. An unscoped query is a data leak, not a slowdown. In tests we also register a DB::listen guard that fails any query against a tenant table with no tenant_id in the binding during isolation test runs.

Where isolation actually leaks

Global scopes cover Eloquent. Production leaks come from everywhere else:

  1. Route model binding. Route::get('/invoices/{invoice}', ...) will happily resolve another tenant's ID if the model has no scope. Always bind through scoped relations or ->whereBelongsTo($tenant).
  2. Cache keys. A cache key of users:{$id} is a cross-tenant collision waiting to happen. Prefix every key with the tenant: tenant:{$id}:users:.... The same applies to rate limiters and locks.
  3. Queued jobs. A job serialised to the queue runs later, in a worker process that has no tenant context. Put tenant_id in the payload and re-establish context at the start of handle().
  4. Files and exports. Local-disk paths like exports/report.csv are shared across tenants. Namespace storage paths by tenant, and don't rely on the filename being obscure.
  5. Scheduled commands. Any php artisan job that iterates tenants must explicitly foreach (Tenant::cursor() as $tenant) and set context per iteration — never assume one is set.

Testing for isolation, not just features

Feature tests prove the happy path; they rarely prove the unhappy one. Add one test per tenant-scoped model along these lines, and run it on every PR:

it('cannot read another tenants record', function () {
    $a = Tenant::factory()->create();
    $b = Tenant::factory()->create();
    $invoice = Invoice::factory()->for($b)->create();

    actingAsTenant($a)
        ->get("/invoices/{$invoice->id}")
        ->assertNotFound();
});

Plus a cheap invariant check in CI: assert that every table in the tenant table list has a tenant_id column and an index that starts with it — a missing index on (tenant_id, ...) is a performance bomb that only detonates at scale.

The checklist

  • Pick the isolation model from contract and compliance needs, not from fashion
  • One TenantContext resolved in middleware, never scattered through controllers
  • Global scope that fails closed when context is missing
  • Tenant-prefixed cache keys, locks, and rate limiters
  • tenant_id in every queued job payload, restored on the worker
  • A cross-tenant denial test per model, required in CI
  • Composite indexes starting with tenant_id on every tenant table

Multi-tenancy isn't hard because the concepts are complex — it's hard because it's a hundred small decisions that must all point the same direction. Decide the model once, enforce it in one place, and test that the enforcement holds. Everything else is just Laravel.