Multi-tenancy in Laravel sounds straightforward until you trip a bug that exposes one tenant's data to another. We have lived through three of those, all caught in staging, and none of them were the obvious "missing where clause" mistakes. Here is what we ship in production.
Three tenancy models
The classic taxonomy: database-per-tenant, schema-per-tenant, row-level tenancy. Each has a use case.
- Database-per-tenant — strongest isolation, terrible operations cost above ~50 tenants
- Schema-per-tenant — middle ground; works on Postgres, painful on MySQL
- Row-level tenancy — shared tables with a
tenant_idcolumn; cheapest operations, requires discipline
Why we chose row-level
At our scale (6,400 tenants, 31 countries), database-per-tenant would mean 6,400 database connections, 6,400 backup schedules, and a migration nightmare every time we ship a schema change. Row-level tenancy with shared infrastructure gives us one Postgres cluster, one migration command, and one set of indexes to maintain. The trade-off is discipline: every query must filter by tenant_id, and any miss is a security incident.
The Eloquent global scope pattern
Laravel makes the discipline tractable with global scopes. Every tenant-scoped model gets one:
// app/Scopes/TenantScope.php
class TenantScope implements Scope {
public function apply(Builder $builder, Model $model) {
if (auth()->check() && auth()->user()->tenant_id) {
$builder->where($model->getTable() . '.tenant_id', auth()->user()->tenant_id);
}
}
}
// In every tenant-scoped model
protected static function booted() {
static::addGlobalScope(new TenantScope);
}
Combined with an Eloquent creating event that auto-fills tenant_id on insert, this catches 95% of cases. The remaining 5% is what bit us.
Tenant resolution at the request boundary
We resolve tenant identity from the subdomain (tenant1.app.fieldservo.io) in a middleware that runs before any controller. The user's tenant_id in the session must match the subdomain — if not, immediate 403. This single check prevents the "switch tenants by changing the URL" attack class.
Three mistakes that almost wrecked production
Forgetting the cron jobs
Scheduled jobs (invoice reminders, recurring contract rollovers) run without an authenticated user. Global scopes that depend on auth()->user() silently return empty filters. We discovered this when a midnight cron sent every tenant's overdue customers an email from a different tenant's brand. The fix: scoped models in jobs must use withoutGlobalScopes() plus an explicit where('tenant_id', $job->tenant_id).
Cache keys without tenant prefixes
We had a Cache::remember('dashboard.metrics', ...) call. Tenant A's dashboard would hit the cache for tenant B. The fix is obvious in hindsight: every cache key must be prefixed with tenant.{$id}.. We added a custom tenant_cache() helper and grep-forbade direct Cache:: calls in lint.
A leaky search endpoint
A full-text search controller used a raw query with a LIKE clause. The scope did not apply because Eloquent's global scope hooks only fire on the query builder, not on raw DB::select(). The fix: all raw queries must go through a TenantQueryBuilder wrapper that injects the where clause unconditionally.
Lint rule we ship to every Laravel team
Phpstan custom rule that flags any DB:: call outside of a small list of allow-listed builder classes. Annoying for the first sprint; lifesaver thereafter.
Closing
Row-level tenancy in Laravel is the right default for FSM at the scale most micro-SaaS teams will reach. The Eloquent global scope plus a strict middleware boundary covers most of the surface. The cron jobs, the cache layer, and the raw-query escape hatches are where you bleed. Test them ruthlessly.