Why Row-Level Tenancy Is Still the Right Default
Schema-per-tenant and database-per-tenant are compelling for strict compliance requirements, but they introduce operational overhead: migration fan-out, connection pool exhaustion, and backup complexity. For most SaaS products, row-level tenancy — a tenant_id column on every shared table — is the pragmatic starting point. The risk is data leakage. One missing WHERE tenant_id = ? clause and a customer sees another's records. The solution is to make correct behaviour the only easy behaviour.
Resolving the Current Tenant
Store the resolved tenant on a singleton so every layer can read it without touching the request object.
// app/Tenancy/TenantContext.php
final class TenantContext
{
private ?Tenant $current = null;
public function set(Tenant $tenant): void
{
$this->current = $tenant;
}
public function get(): Tenant
{
return $this->current ?? throw new \RuntimeException('No tenant resolved.');
}
public function resolved(): bool
{
return $this->current !== null;
}
}
Bind it as a singleton in a TenancyServiceProvider:
$this->app->singleton(TenantContext::class);
Middleware That Sets the Context
final class ResolveTenantFromSubdomain
{
public function __construct(private TenantContext $context) {}
public function handle(Request $request, \Closure $next): mixed
{
$host = $request->getHost(); // e.g. acme.app.test
$slug = explode('.', $host)[0];
$tenant = Tenant::where('slug', $slug)->firstOrFail();
$this->context->set($tenant);
return $next($request);
}
}
Apply it to the web and api middleware groups, or to a dedicated tenant group for routes that require resolution.
The Global Scope That Does the Heavy Lifting
final class TenantScope implements Scope
{
public function __construct(private TenantContext $context) {}
public function apply(Builder $builder, Model $model): void
{
if ($this->context->resolved()) {
$builder->where($model->getTable().'.tenant_id', $this->context->get()->id);
}
}
}
Add a HasTenant trait that registers the scope and auto-fills tenant_id on creation:
trait HasTenant
{
protected static function bootHasTenant(): void
{
static::addGlobalScope(app(TenantScope::class));
static::creating(function (Model $model): void {
$model->tenant_id ??= app(TenantContext::class)->get()->id;
});
}
}
Apply the trait to every tenant-scoped model. That's the entire enforcement surface.
Testing Isolation with Pest
The most dangerous bug is a query that silently returns cross-tenant rows. Write a Pest dataset test that proves the scope holds:
it('never returns records belonging to another tenant', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
// Seed data under tenant B
app(TenantContext::class)->set($tenantB);
Project::factory()->count(3)->create();
// Query as tenant A — must see zero rows
app(TenantContext::class)->set($tenantA);
expect(Project::count())->toBe(0);
});
Also test that withoutGlobalScope is only reachable from console commands and never from HTTP controllers — an architecture test:
arch('controllers never bypass tenant scope')
->expect('App\Http\Controllers')
->not->toUse('Illuminate\Database\Eloquent\Builder::withoutGlobalScope');
Handling Background Jobs
Jobs run outside the HTTP lifecycle, so the middleware never fires. Serialize the tenant ID into the job and restore the context in the constructor or handle method:
final class ProcessInvoice implements ShouldQueue
{
public function __construct(
private readonly int $tenantId,
private readonly int $invoiceId,
) {}
public function handle(TenantContext $context): void
{
$context->set(Tenant::findOrFail($this->tenantId));
// All Eloquent queries from here are scoped.
}
}
Key Takeaways
- Centralise tenant resolution in a singleton
TenantContext; never read fromrequest()inside models. - A single
HasTenanttrait on every model is your entire enforcement surface — missing it is a code-review concern, not a runtime one. - Write a Pest cross-tenant leakage test for every new model; make it part of your PR template.
- Jobs must restore tenant context explicitly — middleware does not run in the queue worker process.
- Use an architecture test to ban
withoutGlobalScopefrom HTTP controllers.