Shared-Database Multi-Tenancy in Laravel: The Foundation Layer

작성자

카테고리:

← 피드로
DEV Community · Nasrul Hazim · 2026-07-28 개발(SW)

TL;DR

  • Shared-database multi-tenancy = one database, every row tagged with tenant_id, a global scope that filters automatically.
  • Three moving parts: a TenantContext singleton, a BelongsToTenant trait, and composite unique constraints.
  • The real safety net isn’t discipline — it’s a CI test that fails when a tenant_id table 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 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
});

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.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다