# Multi-tenant Laravel: request-scoped guards over trusting your queries
Every tenant leak I have seen came from the same place. Someone wrote a query, the query was correct for the case they had in mind, and nothing in the codebase made them think about the other tenants. The fix people reach for first is a code review habit. Habits do not survive a deadline.
## Resolve the tenant before you resolve the user
The tenant comes from the request host, not from the authenticated user. Middleware looks the hostname up against a domains table, then runs the rest of the request inside a context that holds the tenant. Nothing downstream is allowed to guess it, and nothing reads a tenant id out of a route parameter or a request body, because both are things a client can change.
# middleware: host to tenant, before auth runs $tenant = Domain::query()->with('tenant') ->where('domain', strtolower($request->getHost())) ->routable()->first()?->tenant ?? abort(404); return TenantContext::scope($tenant, fn () => $next($request));
The context itself is Laravel's Context facade with hidden keys, so the tenant travels with the request and does not leak into logs. scope() restores whatever was there before it in a finally, which is what makes it safe to nest.
## Three layers, each cheap
- ▸One trait on every tenant-owned model. It adds a global scope that filters on
tenant_id, and asavinghook that overwritestenant_idfrom the context. The scope stops the reads, the hook stops a forged id in a request payload from landing in the database. - ▸Spatie Permission in teams mode. The team id comes from a resolver that reads the tenant context live on every call, and
setPermissionsTeamIdis a deliberate no-op. Under Octane and long-lived queue workers, a team id you can set is a team id that can go stale between requests. - ▸Validation rules that carry the tenant.
existsanduniquechecks run through tenant-aware rules, because a plainRule::exists()happily confirms that some other tenant owns the id you just posted.
A correct query is a good thing. A layer that makes the incorrect query impossible is a better one.
## Where the scope has to come off
Central admin endpoints, provider webhooks, and impersonation genuinely need to read across tenants. Those call withoutGlobalScope(TenantScope::class) by name rather than withoutGlobalScopes(), and the rule is that the reason stays visible in the surrounding code. It is not one tidy file, and pretending otherwise would be the wrong lesson. Nine files is small enough to grep and read in an afternoon, which is the property that actually matters.
## The parts that still bite
Queued jobs do not inherit the context, so they take a tenant id in the constructor, load the tenant in handle(), and do their work inside TenantContext::scope. Scheduled commands have no request at all and loop every tenant the same way. Anything cached outside a request, like an OAuth state key, stores the tenant id in its payload and checks it on the way back, because the callback arrives on a URL you do not control.
## How I test it
A switchTenant() helper makes the interesting tests short. Create a record, switch tenant, assert the count is one and the row is the other one's. On top of that there is a test that walks the tenant-owned models and proves the scope applies to each, a set that posts another tenant's ids into every payload that accepts a foreign key, and one that route-binds a model from the wrong tenant and expects a 404 rather than a 403, because a 403 tells you the record exists.
None of these are clever. They are the ones I would keep if I could only keep a handful.