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
}
}
Enter fullscreen mode Exit fullscreen mode
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();
}
});
}
// ...
}
Enter fullscreen mode Exit fullscreen mode
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 identifiersunique([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
});
Enter fullscreen mode Exit fullscreen mode
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.
답글 남기기