# Multi-tenant Laravel: request-scoped guards over trusting your queries
A tenant leak does not look like a mistake in review. It looks like Employee::where('department_id', $id)->get(), which is a correct query, written by someone who was thinking about one company because that is the company they had in their head while writing it. There is nothing to catch. The line is right, and it is right for the wrong number of tenants.
The usual response is for the team to agree to be careful, and that agreement holds until the first deadline. So the isolation in this codebase sits underneath the place where anyone writes a query.
## 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.
# the whole trait, both halves protected static function bootBelongsToTenant(): void { static::addGlobalScope(new TenantScope()); static::saving(function ($model) { $model->tenant_id = TenantContext::id(); }); }
## 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. That spreads the exceptions over nine files rather than one tidy list, which is fine: nine files is small enough to grep and read in an afternoon.
# the reason sits on the line above, every time # webhook: the provider posts to one URL for all tenants Invoice::withoutGlobalScope(TenantScope::class) ->where('provider_ref', $ref) ->firstOrFail();
## 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.
# a job has no request, so it carries the id and rebuilds the context public function handle(): void { TenantContext::scope( Tenant::findOrFail($this->tenantId), fn () => $this->sendPayslips(), ); }
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.
# the shortest test in the suite, and the one I trust most Employee::factory()->create(); # belongs to tenant one switchTenant($other); $this->assertSame(0, Employee::count());
On top of that, a test walks the tenant-owned models and proves the scope applies to each, a set posts another tenant's ids into every payload that accepts a foreign key, and one route-binds a model from the wrong tenant and expects a 404. A 403 would confirm to the caller that the record exists.
None of these are clever. They are the ones I would keep if I could only keep a handful.