Skip to content
Laravel Multitenancy10 min read·

Tenant-aware queues, caches and logs: three places Laravel multi-tenancy leaks

A global scope protects queries. A restored job model, a cache hit and a log line are the three places real work happens without one, which is exactly why they leak.

LaravelMultitenancyQueuesCachingObservabilityTenant Isolation

Search for Laravel multi-tenancy and you get the schema debate: a tenant_id column, a schema per tenant, or a database per tenant. Pick a model, add a global scope, ship it. I wrote the overview of all eight boundaries partly to make the point that the schema is the least interesting of them.

This is the deep version of three of those boundaries, because they share a property that nothing I have read about Laravel tenancy names out loud.

A global scope is a query modifier. It works by rewriting queries that go through Eloquent. The queue, the cache and the log line are the three places in a Laravel application where real work happens and no scoped query runs at all — so the one defence everybody installs is structurally unable to reach any of them. That is not a bug in your implementation. It is the shape of the tool.

Why the global scope cannot help you here

Put the four cases side by side and the gap stops being surprising.

Where the work happensDoes a scoped query run?What that means
A controller reading modelsYes, through Eloquent.Covered. This is the only case the articles are about.
A queued job restoring its modelsA query runs — deliberately unscoped.Laravel re-fetches serialised models by primary key without global scopes, by design. Your trait is not consulted.
A cache hitNo. Avoiding the query is the entire point.The rows were scoped once, for whoever warmed the key. Everyone after that gets their copy.
A log line or exception reportThe query already finished.The data is in a payload on its way to a third party. There is nothing left to scope.

So the rule for the rest of this: tenancy in these three layers is established by the transport, not by the model. Each one needs its own mechanism, and all three mechanisms are about twenty lines.

Queues: the model you restored was never scoped

The overview covers the obvious half — a job must carry its tenant in the payload rather than reading it from whatever the worker last set. Here is the half that survives even after you fix that.

SerializesModels does not serialise your models. It stores the class name and the primary key, then re-fetches on the way out, and the restoration query is built with newQueryWithoutScopes(). That is correct framework behaviour — a job should be able to restore a soft-deleted or otherwise hidden record — but it means this job reads another tenant's invoice with a perfectly installed BelongsToTenant trait sitting right there:

php
class SendInvoiceReminder implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    // Looks airtight. The tenant is even in the payload.
    public function __construct(
        public readonly string $tenantId,
        public readonly Invoice $invoice,
    ) {}

    public function handle(): void
    {
        Tenancy::for($this->tenantId, function () {
            // $this->invoice was restored by primary key, before this
            // callback ran, with global scopes switched off. If the id in
            // the payload belongs to another tenant, it is already loaded.
            Mail::to($this->invoice->contact)->send(new Reminder($this->invoice));
        });
    }
}

In normal operation nothing goes wrong, because the id was written by a correctly scoped request. It goes wrong when an id is replayed, guessed, or taken from somewhere you did not audit: a retry carrying an old payload, a job dispatched from a webhook, an admin tool, a CSV import, a support script. The job then does exactly what it was asked to do, against the wrong tenant, and reports success.

Two fixes, and you want both. Pass ids rather than models when a job crosses a tenant boundary, and re-resolve them inside the tenant context so the scope applies. Then stop relying on anybody remembering, by making the framework attach and establish tenancy for every job:

php
// AppServiceProvider::boot()

// 1. Stamp the current tenant onto every job payload, including the jobs
//    inside packages you did not write and did not think about.
Queue::createPayloadUsing(fn () => [
    'tenant_id' => Tenancy::currentId(),
]);

// 2. Establish it before the job runs, and clear it afterwards so nothing
//    leaks into the next job this long-lived worker picks up.
Queue::before(function (JobProcessing $event) {
    $tenantId = $event->job->payload()['tenant_id'] ?? null;

    $tenantId
        ? Tenancy::set($tenantId)
        : Tenancy::forget();   // a job with no tenant gets none, not the last one
});

Queue::after(fn () => Tenancy::forget());
Queue::failing(fn () => Tenancy::forget());

Tenancy::forget() on the failure path matters more than it looks. A failed job leaves the worker mid-flight, and without that line the next job inherits a tenant from the job that just crashed. Retries make it worse: a job with $tries = 3 and a backoff runs its second attempt minutes later, and retryUntil can push the third one hours out, in a process that has handled dozens of other tenants in between. The attempt that succeeds is often not the attempt you tested.

Then the three dispatch paths that skip all of this:

  • Scheduled commands. schedule:run has no request and therefore no tenant. A nightly command that iterates tenants must set the context per iteration and clear it after, and a failure in tenant nine must not abandon tenants ten through two hundred.
  • Chains and batches. Tenancy established in the first link is gone by the second, because each link is a separate job on a possibly different worker. The payload stamp above covers this; a Tenancy::for() wrapper inside one job's handle() does not.
  • Queued notifications and mailables. ShouldQueue on a notification produces a job you never wrote, carrying a notifiable restored by key. This is the single most common source of the email-to-the-wrong-customer bug.

Cache: a hit is not a query

Putting the tenant in the key is the part everyone eventually does, and the overview has the snippet. The harder problem is what you are storing.

A cached Eloquent collection is a set of rows that were scoped once, at write time, by whichever tenant warmed the key. Read it back and no scope runs, because there is no query to rewrite — you are handing back serialised models. So a perfect BelongsToTenant trait and a leaking dashboard are completely compatible, and the leak survives every ORM-level audit you run.

Which gives a blunt rule worth more than a hundred careful keys: cache values, not rows. A count, a total, a list of ids. If you cache models, you have moved tenant-owned data into a store that has no concept of tenants and no scope to apply when it comes back out.

Rendered fragments are the exception that catches people, because they are values by this definition and still leak. A cached navigation partial carries a logo, a company name and a feature-flag state, so the first tenant to load the page decides what everybody else sees branded across the top of theirs. It is not a data breach and it is the one your customers notice within the hour. Fragment keys need the tenant and anything else that varies the render: locale, plan, role.

Then the three operational edges:

php
// Tags give you per-tenant flushing, but only on redis and memcached.
// On the file, database or array store, tags are silently unavailable —
// and the array store is what your tests run on, so a tagged flush can
// pass in CI and throw in production.
Cache::tags(['tenant:'.Tenancy::currentId()])->flush();

// This is the one that takes everybody down. It is in more deploy
// scripts and more "clear the cache" admin buttons than you would like.
Cache::flush();   // every tenant, every key, including other apps
                  // sharing this Redis database

// The real fix for a switching app: move the prefix when the tenant
// moves, so keys cannot collide even when a package forgets.
Tenancy::onSwitch(function (?Tenant $tenant) {
    Cache::setPrefix($tenant ? "t{$tenant->id}:" : config('cache.prefix'));
});

That last block is the one I reach for on an existing codebase, because it fixes the keys you do not control. Rate limiters, session stores, package-level memoisation, a remember() buried in a composer dependency — none of them know your tenant exists, and all of them become safe when the prefix moves underneath them. Spatie's multitenancy package ships a switching task that does exactly this, and it is the strongest argument for using the package rather than hand-rolling the switch.

One more, because it is invisible until it is an incident: if Redis is also your session, queue and lock store, those keys live in the same keyspace. Separate databases, or distinct prefixes per concern, so that a cache flush cannot log out every user and drop every pending job at the same time.

Logs: the boundary that leaks to your vendors

The overview says tag every line with the tenant id, and that is the easy direction. The uncomfortable direction is what Laravel's own observability tooling writes down.

Telescope records requests with their payloads, queries with their bindings, mail with its rendered content, notifications, and job payloads. It writes all of it into telescope_entries in your application database — unscoped, by design, because it is a debugging tool. Its dashboard is protected by a gate that in a great many applications is still the generated one: return in_array($user->email, [...]) or an isLocal() check that stopped being true the day you enabled it in production to chase a bug. You now have a complete, searchable, cross-tenant archive of your customers' data behind an email allowlist.

Horizon is milder but the same shape: job payloads are visible to anyone who can reach the dashboard, and after the queue section above, your payloads contain tenant ids and often a good deal more.

php
// TelescopeServiceProvider: in production, record the shape, not the contents.
Telescope::filter(function (IncomingEntry $entry) {
    if (app()->isLocal()) return true;

    // Keep what helps you operate. Drop what carries customer data.
    return $entry->isReportableException()
        || $entry->isFailedJob()
        || $entry->isScheduledTask();
});

// And tag what you do keep, so an investigation can be scoped to one customer.
Telescope::tag(fn (IncomingEntry $entry) => array_filter([
    Tenancy::currentId() ? 'tenant:'.Tenancy::currentId() : null,
]));

The same audit applies to your error tracker. Sentry, Bugsnag and friends attach request bodies, user context and local variables by default, which means a validation error on a billing form can send a card holder's name and address to a third-party service, where it sits in a project that your whole engineering team — and that vendor's support staff — can read. Configure the scrubbing explicitly and verify it by triggering a real error and reading what arrived, because the defaults are tuned for debuggability, not for a product where one customer's data must never be visible while investigating another's.

And inside a worker, log context is per-process state like everything else here. Log::withContext(['tenant' => $id]) set during one job will happily attribute the next job's lines to the wrong customer unless it is established per job and cleared after — the same Queue::before / Queue::after pair, doing double duty.

Three tests, one per layer

The overview's test asserts a controller cannot read across tenants. These three assert the layers underneath it, and each one fails on a codebase that passes the first.

php
it('refuses to process a job whose payload belongs to another tenant', function () {
    [$acme, $globex] = Tenant::factory()->count(2)->create();
    $invoice = Invoice::factory()->for($globex)->create();

    // The scenario: a worker that just handled Acme picks up a job
    // carrying a Globex id. Call handle() directly — do not dispatch,
    // because dispatching is what hides this.
    Tenancy::set($acme->id);

    expect(fn () => (new SendInvoiceReminder($acme->id, $invoice->id))->handle())
        ->toThrow(ModelNotFoundException::class);
});

it('does not serve one tenant a cache entry warmed by another', function () {
    [$acme, $globex] = Tenant::factory()->count(2)->create();

    Tenancy::set($acme->id);
    Invoice::factory()->for($acme)->count(3)->create();
    $acmeTotal = app(Dashboard::class)->outstandingTotal();   // warms the key

    Tenancy::set($globex->id);
    expect(app(Dashboard::class)->outstandingTotal())
        ->not->toBe($acmeTotal)
        ->toBe(0);
});

it('writes no cross-tenant payload to telescope in production', function () {
    app()->detectEnvironment(fn () => 'production');

    Tenancy::set(Tenant::factory()->create()->id);
    $this->get('/dashboard')->assertOk();

    expect(DB::table('telescope_entries')->where('type', 'request')->count())
        ->toBe(0);
});

The middle one is the test I would write first on an existing application, because it needs no attacker and no replay. Two tenants, one cached method, run it twice. It either passes, which tells you something real, or it fails in about forty milliseconds and you have found a live leak.

Where to put the boundary

All three fixes have the same shape, and it is the shape of every durable decision in a Laravel codebase: the rule lives in a place where it cannot be skipped, rather than in a place where it has to be remembered. Payload stamping in a service provider, not a base class people must extend. A cache prefix that moves with the tenant, not a key convention people must follow. A Telescope filter, not a policy about what to look at.

That is the same instinct as keeping the controllers boring, applied one layer down. A convention in a wiki holds for about four months. A framework hook holds until somebody deletes it, and deleting it shows up in a diff.

The schema decides what a query can see. The queue, the cache and the log decide what happens when nobody runs a query, and that is where multi-tenancy actually leaks.

If this is already live and you want to know which of the three you have, the order is: grep for SerializesModels on jobs that take a model, grep for remember returning anything that looks like a collection, then open your Telescope dashboard and see how far back it goes. An afternoon, and you will know whether you are looking at a tidy-up or a project.

If you would rather have someone go through it with you, I review multi-tenant Laravel codebases for exactly these boundaries and hand back a prioritised list with the tests to prove each fix. Book a SaaS architecture review.

Building something like this?

I design and ship these systems for clients: retrieval over private data, agents that complete real tasks, and the Laravel platforms underneath them.

Keep reading