Skip to content
Backend8 min

Row-level multi-tenancy in Laravel: the four leak modes

Past the basics. The four places where data quietly leaks across tenants in a single-database Laravel SaaS — and how to plug each one.

Row-level multi-tenancy in Laravel: the four leak modes

Single-database multi-tenancy in Laravel sounds simple on day one: add a `tenant_id` column to every relevant table, register a global scope on each model, set the active tenant in middleware. You're isolated. You ship.

Then a queue worker runs at 3am and posts the wrong tenant's invoices. Or a redis cache key collides between two clients with the same email. Or a developer adds an eager-load and silently bypasses the global scope. Multi-tenancy isn't broken by the code you wrote — it's broken by the four places the request scope quietly disappears. Here's where to look.

1. Eager loads that bypass global scopes

A `User` model with a `belongsToMany('orders')` relationship looks fine. But when you eager-load it — `User::with('orders')->get()` — Laravel calls Order's relationship loader directly, and depending on how you've registered the global scope, it can run without it. The query goes out without the `tenant_id` filter and returns every order in the database.

Two reliable fixes. First, register the global scope inside the `boot()` method on every tenant-bound model and verify it shows up in the relationship loader's query. Second, write a feature test that explicitly checks `User::with('orders')->toSql()` contains the tenant filter — once, then never have to think about it again.

// app/Models/Concerns/BelongsToTenant.php
trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope('tenant', function (Builder $q) {
            if ($id = app(TenantContext::class)->id()) {
                $q->where($q->getModel()->getTable() . '.tenant_id', $id);
            }
        });

        static::creating(fn ($m) => $m->tenant_id ??= app(TenantContext::class)->id());
    }
}

2. Queue jobs running without tenant context

The web request resolves the tenant from the subdomain, the route, or a JWT claim. Then the request ends. The job you dispatched runs ten seconds later in a worker that has no idea who the tenant was. If the job calls `Order::find($id)`, the global scope tries to read the tenant from a context that doesn't exist — and either fails closed (no results, silent breakage) or fails open (no scope, all tenants).

The fix is to pass tenant ID into every job constructor and bind it back into the container at the start of `handle()`. Or use a queue middleware that does this for any job tagged with a `TenantAware` interface. Don't rely on `Auth::user()` in queues — it's null.

class SendInvoiceEmail implements ShouldQueue, TenantAware
{
    public function __construct(
        public int $tenantId,
        public int $invoiceId,
    ) {}

    public function handle(): void
    {
        // TenantAwareMiddleware has already bound the tenant context.
        $invoice = Invoice::findOrFail($this->invoiceId);
        // ...
    }
}

3. Cache keys and Redis namespaces

`Cache::remember('user-roles', 300, fn () => …)` returns the roles for whichever tenant warmed the cache first. Every subsequent tenant reads the wrong data until the TTL expires. This is the single most common production multi-tenancy bug we see.

Either prefix every cache key with the tenant ID (a small wrapper around `Cache::store()` makes this automatic), or run a per-tenant cache store. The wrapper is cheaper. Redis namespacing via the `prefix` config option also works but only if the prefix is set per request, which means you can't share a connection pool naively.

4. Eloquent observers and side-effect handlers

An `OrderObserver::created()` that fires `Auth::user()->notify(...)` works fine when the order is created in a controller. It crashes when the order is created in a queued job, a console command, a webhook handler, or a broadcast event — anywhere `Auth::user()` is null.

Treat observers as if they always run with no auth context. Pass tenant explicitly through the model — `$order->tenant_id` is always there because of rule 1 — and resolve the responsible user (or a system user) from the model itself, never from the request.

The pattern across all four is the same: multi-tenancy is about defaulting safely. Default to tenant-scoped queries, default to passing tenant context explicitly through async boundaries, default to assuming there's no request to lean on. Escape the defaults only with explicit `withoutGlobalScopes()` calls that pass code review.

Audit every place the request scope dies — queues, cron, observers, broadcasts, webhooks — and decide what tenant context applies. Write the test that proves it. Multi-tenancy bugs in production are almost never caught by manual QA; they're caught by the customer support ticket that says "why am I seeing someone else's data?"

Working on something like this?

We build production software for teams whose problems don't fit a template. Tell us what you're working on — we'll tell you how we'd build it.

Start a conversation