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:
- 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). - 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. - Queued jobs. A job serialised to the queue runs later, in a worker process that has no tenant context. Put
tenant_idin the payload and re-establish context at the start ofhandle(). - Files and exports. Local-disk paths like
exports/report.csvare shared across tenants. Namespace storage paths by tenant, and don't rely on the filename being obscure. - Scheduled commands. Any
php artisanjob that iterates tenants must explicitlyforeach (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
TenantContextresolved in middleware, never scattered through controllers - Global scope that fails closed when context is missing
- Tenant-prefixed cache keys, locks, and rate limiters
tenant_idin every queued job payload, restored on the worker- A cross-tenant denial test per model, required in CI
- Composite indexes starting with
tenant_idon 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.