Every multi-tenant conversation starts in the wrong place: “one database per tenant, or one shared database?”
That’s a real question, but it’s not the one that bites you. What bites you six months in is the query someone wrote at 11 PM that forgot where('tenant_id', ...), the queued job that ran with the wrong tenant bound, and the cache key that served customer A’s dashboard to customer B.
Multi-tenancy isn’t a schema decision. It’s a discipline problem, and the patterns below are the ones I keep coming back to because they make the discipline automatic instead of optional.
First, Pick the Boring Option
There are three common models:
| Model | Isolation | Operational cost |
|---|---|---|
| Database per tenant | Strongest | Highest — migrations, backups, connections ×N |
| Schema per tenant | Strong | High, and very database-specific |
Shared database + tenant_id | Weakest by default | Lowest |
The industry keeps romanticizing database-per-tenant. It does give you the best isolation story and the easiest “export my data” conversation. It also gives you N migrations to run on every deploy, N backups to verify, and a connection pool that gets interesting around a few hundred tenants.
Start with a shared database and a tenant_id column. Move to database-per-tenant when a specific requirement forces you — a compliance clause, a customer big enough to deserve their own blast radius, a noisy-neighbor problem you’ve actually measured. Not before.
The rest of this post assumes the shared model, because that’s where the discipline matters most.
Pattern 1: Resolve the Tenant Once, at the Edge
Tenant resolution belongs in one place — middleware — and the result belongs in the container, not in a global.
class ResolveTenant
{
public function handle(Request $request, Closure $next): Response
{
$tenant = Tenant::query()
->where('subdomain', $request->getHost())
->firstOrFail();
app()->instance(Tenant::class, $tenant);
return $next($request);
}
}
Subdomain, path prefix, or a claim on the token — pick one and be consistent. What you should not do is let each controller figure out the tenant from whatever happens to be in the request. The moment there are two ways to answer “who is the current tenant?”, they will eventually disagree.
Pattern 2: Make Forgetting Impossible
This is the single highest-leverage pattern in the whole post. Never rely on developers remembering a where clause. Push it into the model with a global scope, and fill it on create:
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $builder) {
$builder->where(
$builder->getModel()->getTable().'.tenant_id',
app(Tenant::class)->id
);
});
static::creating(function (Model $model) {
$model->tenant_id ??= app(Tenant::class)->id;
});
}
}
Now a plain Invoice::find($id) is safe by construction. A developer who knows nothing about your tenancy model writes correct code by default.
Two things to guard:
withoutGlobalScope()is a code smell. Grep for it in review. There are legitimate uses (admin tooling, cross-tenant reports), but each one should be deliberate and obvious.- The scope does not cover
DB::table(). Raw query builder calls bypass Eloquent entirely, and so does anything in a rawselectRawsubquery. If your codebase leans onDB::table(), that’s where your leak will be.
Pattern 3: Scoping Is Not Authorization
A global scope answers “which rows exist for this tenant?” It does not answer “is this user allowed to touch this row?”
Those are different questions, and conflating them produces a subtle bug: a user with a read-only role can still mutate anything inside their own tenant, because the scope let the query through and nobody checked the policy.
Keep both layers:
public function __invoke(UpdateInvoiceRequest $request, Invoice $invoice)
{
// Scope already guaranteed $invoice belongs to the current tenant.
// The policy decides whether *this user* may update it.
$this->authorize('update', $invoice);
// ...
}
A useful side effect of scoping first: a cross-tenant ID returns 404, not 403. You don’t confirm that the resource exists somewhere else, which is exactly what you want — the same anti-enumeration logic you’d apply to a login endpoint.
Pattern 4: Everything Outside the Request Forgets the Tenant
This is where most implementations quietly break. Your middleware bound the tenant to the container for the duration of an HTTP request. Queue workers, scheduled commands, and broadcast listeners have no such request.
Worse: a long-lived worker keeps the container from the previous job. Bind the tenant explicitly, every time:
class GenerateMonthlyReport implements ShouldQueue
{
public function __construct(public int $tenantId) {}
public function handle(): void
{
$tenant = Tenant::withoutGlobalScopes()->findOrFail($this->tenantId);
app()->instance(Tenant::class, $tenant);
// ... now tenant-scoped models behave correctly
}
}
Never serialize the full tenant model into the payload and trust it — pass the ID and re-resolve. And for scheduled work that spans all tenants, be explicit about the loop:
Tenant::withoutGlobalScopes()->cursor()->each(
fn (Tenant $tenant) => GenerateMonthlyReport::dispatch($tenant->id)
);
One job per tenant, not one job that iterates internally. If tenant 47 throws, tenants 48 through 900 still get their reports.
Pattern 5: Namespace Everything That Isn’t a Table
Rows are the obvious shared surface. The non-obvious ones cause the worst incidents:
- Cache keys.
cache()->remember('dashboard_stats', ...)will happily serve one tenant’s numbers to another. Prefix with the tenant ID — or better, wrap it in a helper so nobody has to remember. - File storage. Put uploads under
tenants/{id}/...and never accept a client-supplied path segment. - Full-text / search indexes. A shared index needs a tenant filter baked into every query, not appended by the caller.
- Rate limiters and locks. A lock named
report-generationis a cross-tenant traffic jam waiting to happen.
Pattern 6: Composite Unique Indexes
users.email being globally unique is fine for a single-tenant app and actively wrong for a multi-tenant one — the same person working with two of your customers can’t sign up twice.
$table->unique(['tenant_id', 'email']);
Same for slugs, invoice numbers, SKUs, and every other “unique” your product owner asks for. Ask “unique within what?” every single time.
Pattern 7: Test Cross-Tenant Isolation as a Standing Requirement
Isolation is a security property, and security properties need tests that can fail. Make it a required case in every feature test for anything tenant-owned:
#[Test]
public function no_puede_leer_datos_de_otro_tenant(): void
{
$other = Invoice::factory()->for(Tenant::factory())->create();
$this->actingAs($this->user)
->get(route('invoices.show', $other))
->assertNotFound();
}
Write it for reads and writes. A GET that 404s while the corresponding PUT succeeds is a real bug I’ve seen more than once, usually because the write path used a different lookup.
What Actually Breaks in Production
Ranked by how often I’ve seen it:
- A
DB::table()call somewhere in a report or an export, bypassing every scope you wrote. - A job that ran with a stale container binding on a long-lived worker.
- A cache key without a tenant prefix, usually added during a performance push under deadline.
- A
withoutGlobalScope()that was temporary six months ago. - A globally unique index that blocks a legitimate signup — the harmless one, and the one you’ll hear about first.
None of these are architecture failures. They’re all discipline failures, which is why the patterns worth adopting are the ones that remove the need for discipline.
The Short Version
Shared database until proven otherwise. Resolve the tenant once. Scope automatically so forgetting isn’t possible. Keep authorization as a separate layer. Re-bind the tenant in every queued job. Namespace caches, files and locks. Composite uniques. Test isolation like you’d test auth — because that’s what it is.
Multi-tenancy doesn’t fail loudly. It fails as one customer seeing another customer’s data, once, in a way you find out about from a support ticket. Build so that the failure mode requires effort, not just forgetfulness.