TL;DR
- Shared-database multi-tenancy = one database, every row tagged with
tenant_id, a global scope that filters automatically. - Three moving parts: a
TenantContextsingleton, aBelongsToTenanttrait, and composite unique constraints. - The real safety net isn't discipline — it's a CI test that fails when a
tenant_idtable forgets the trait (or vice versa).
I shipped the foundation layer for shared-database multi-tenancy this week. Not the routing, not the resolver — just the part that makes "one row belongs to one organisation" true and hard to get wrong. Here's the shape of it.
Why shared-database
Three broad strategies exist. Quick trade-off:
| Strategy | Isolation | Ops cost | Good when |
|---|---|---|---|
| Database-per-tenant | Strongest | Highest (migrations × N) | Few large tenants, strict compliance |
| Schema-per-tenant | Strong | Medium | Postgres, moderate tenant count |
Shared DB + tenant_id
|
Weakest | Lowest | Many tenants, one codebase, one migration run |
For a greenfield app with many organisations sharing one codebase, shared-database wins on simplicity. The catch: isolation is now your job, enforced in application code. Forget one where tenant_id = ? and one org sees another's data. So the whole design is about making that impossible to forget.
The context object
Everything reads the current tenant from one singleton, not from the request, the session, or a global. That keeps the resolver swappable — config-based on-premise, domain-based on SaaS — without touching anything downstream.
class TenantContext
{
private ?Tenant $tenant = null;
private bool $scopeDisabled = false;
public function id(): ?int { return $this->tenant?->getKey(); }
public function has(): bool { return $this->tenant instanceof Tenant; }
public function shouldScope(): bool
{
return ! $this->scopeDisabled && $this->has();
}
public function withoutScope(Closure $callback): mixed
{
// suspend scoping for genuine cross-tenant work, then restore
}
}
shouldScope() returning false when no tenant is resolved matters: console bootstrapping, seeding, and early tests run before a tenant exists. No tenant means nothing to filter by — not "filter by null".
The trait
One trait does two jobs: add the global scope on read, fill tenant_id on write.
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
$context = app(TenantContext::class);
static::addGlobalScope('tenant', function (Builder $q) use ($context) {
if (! $context->shouldScope()) return;
$q->where($q->getModel()->getTable().'.tenant_id', $context->id());
});
static::creating(function ($model) use ($context) {
if ($model->tenant_id === null && $context->has()) {
$model->tenant_id = $context->id();
}
});
}
// ...
}
Table-qualifying the column (table.tenant_id) avoids ambiguous-column errors the moment you join two scoped tables.
Uniqueness gets subtle
Under shared-database, "unique" splits in two. A human-readable identifier a tenant reuses must be unique per tenant. A security token must stay unique globally — a collision there is an attack surface, not a UX papercut.
| Column type | Scope | Example |
|---|---|---|
| Human identifiers | unique([tenant_id, x]) |
member number, invoice number, slug |
| UUIDs & tokens | Globally unique | qr token, idempotency key, gateway reference |
Users stay central too: one email, one login, membership across many orgs via a pivot. The person isn't tenant-scoped; their membership is.
The part that actually keeps you safe
Traits and constraints are only as good as your memory of applying them. So the guardrail is a test, not a checklist:
it('scopes every model whose table carries tenant_id', function () {
$missing = [];
foreach (tenancyModelClasses() as $class) {
$table = (new $class)->getTable();
if (Schema::hasColumn($table, 'tenant_id') && ! usesBelongsToTenant($class)) {
$missing[] = $class;
}
}
expect($missing)->toBe([]); // CI fails, names the offenders
});
It checks both directions: a tenant_id table without the trait, and a trait without the column. Add a new model six months from now, forget the trait, and CI tells you before review does.
Takeaway
Shared-database tenancy is cheap to run and easy to leak. The fix isn't being careful — it's turning "did we remember?" into a failing test. Resolver, scope, and uniqueness are the moving parts; the guardrail is what lets you sleep.
Top comments (0)