Skip to content
root@hisham:~/blog/posts$ glow multi-tenant-laravel.md
multi-tenant-laravel.md
· 4 min read · laravel

# Multi-tenant Laravel: request-scoped guards over trusting your queries

Tenant isolation belongs below the query layer, not in the head of whoever writes the next controller.

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 a saving hook that overwrites tenant_id from 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 setPermissionsTeamId is 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. exists and unique checks run through tenant-aware rules, because a plain Rule::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.

Questions or corrections: hishammedhat0@gmail.com github
root@hisham:~/blog/posts$