# Multi-Tenant Laravel: The Isolation Bugs No Scanner Will Find

> Every external security tool will give a multi-tenant Laravel app a clean bill of health while one customer reads another customer's invoices. Tenant isolation is a business-logic property, and it fails in five places that look completely normal in code review.

**Author:** Matt King | **Published:** August 25, 2026 | **Category:** Security

---

An external scanner will tell you a multi-tenant application is healthy while customer A reads customer B's invoices all day. Headers present, TLS configured, no exposed environment file, framework current. Clean report, serious breach.

That is not a scanner failing. It is a category difference. Configuration problems are visible from outside because they are properties of the deployment. Tenant isolation is a property of your domain, and nothing outside your team knows what the boundary is supposed to be.

These are the five places I most often see it break, in rough order of how normal the broken code looks.

## 1. Anything that bypasses Eloquent

Most Laravel multi-tenancy starts with a global scope, and it works well:

```php
class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where($model->getTable().'.tenant_id', auth()->user()->tenant_id);
    }
}
```

Every `Invoice::find()`, every `Invoice::where()`, correctly scoped. Then somebody writes a reporting query.

```php
// no global scope anywhere near this
$rows = DB::table('invoices')
    ->join('line_items', 'invoices.id', '=', 'line_items.invoice_id')
    ->select('invoices.id', 'line_items.amount')
    ->get();
```

The query builder does not know your model exists. Neither does a raw statement, or a `DB::select`, or a stored procedure. Reporting, exports, admin dashboards, and data-migration commands are where these appear, because those are the places people reach past the ORM for performance or convenience.

The join is the subtler version. Even when the base query is properly scoped through Eloquent, joining onto a table that has no scope of its own can pull in rows across tenants:

```php
// invoices is scoped, line_items is not
Invoice::join('line_items', 'invoices.id', '=', 'line_items.invoice_id')
    ->select('line_items.*')
    ->get();
```

The scope constrains `invoices`. The selected columns come from `line_items`. Whether this leaks depends entirely on your foreign keys being trustworthy, which is a weaker guarantee than an explicit filter.

Grep for the bypass patterns periodically and treat each hit as something requiring a deliberate justification:

```bash
grep -rn "DB::table\|DB::select\|DB::raw\|->join(" app/ --include="*.php"
```

## 2. Queued jobs have no tenant

This is the one that surprises people, and it is common.

```php
class GenerateMonthlyReport implements ShouldQueue
{
    public function __construct(public int $reportId) {}

    public function handle(): void
    {
        // auth()->user() is null here. Always.
        $invoices = Invoice::whereMonth('created_at', now()->month)->get();
    }
}
```

A worker process has no session and no authenticated user. `auth()->user()` returns null, so the global scope either throws or, worse, resolves `tenant_id` to null and applies a filter that matches nothing or everything depending on how it was written.

The version that fails loudly is fine. The version that silently returns every tenant's invoices, formats them into a report, and emails that report to one customer is a data breach dispatched by your own scheduler.

Pass the tenant explicitly and re-establish it:

```php
class GenerateMonthlyReport implements ShouldQueue
{
    public function __construct(
        public int $tenantId,
        public int $reportId,
    ) {}

    public function handle(): void
    {
        Tenant::current($this->tenantId);          // explicit, not inferred
        $invoices = Invoice::whereMonth('created_at', now()->month)->get();
    }
}
```

The rule that prevents the whole class: a job should never infer tenant context, only receive it. The same applies to scheduled commands, listeners, and anything else running outside a request. There is more on the general hazards of background work in [securing Laravel queues and background jobs](/blog/securing-laravel-queues-background-jobs).

## 3. Cache keys without a tenant

```php
$settings = Cache::remember('billing_settings', 3600, fn () => Setting::billing());
```

The query inside the closure is scoped correctly. The cache key is not. Whichever tenant triggers the miss populates the cache, and every other tenant reads their data for the next hour.

This one is nasty because it is intermittent by nature. It depends on who arrives first after expiry, so it fails differently on every request cycle and is close to impossible to reproduce deliberately. Teams often chase it for weeks as a "weird caching bug".

```php
$settings = Cache::remember(
    "tenant:{$tenant->id}:billing_settings",
    3600,
    fn () => Setting::billing(),
);
```

A helper that builds every key with the tenant prefix baked in is worth the twenty lines, because this is a mistake that will otherwise recur every time someone adds caching to a hot path under deadline pressure.

Rate limiters have the same shape. A limiter keyed only on a route name pools every tenant into one bucket, so one heavy customer throttles everybody else, which is a denial of service delivered by your own protective measure.

## 4. Predictable file paths

```php
$path = $request->file('document')->store('documents');
// storage/app/documents/8fT2kd9s.pdf
```

Then the download route:

```php
Route::get('/documents/{filename}', function ($filename) {
    return Storage::download("documents/{$filename}");
});
```

No tenant anywhere in the path, and no ownership check on retrieval. Anyone with a filename can fetch anyone's file. Random filenames make it harder to guess, which is not the same as making it safe, and filenames leak constantly through logs, referrer headers, support tickets, and browser history.

Put the tenant in the path and check ownership on the way out:

```php
$path = $request->file('document')->store("tenants/{$tenant->id}/documents");

Route::get('/documents/{document}', function (Document $document) {
    abort_unless($document->tenant_id === auth()->user()->tenant_id, 404);
    return Storage::download($document->path);
})->middleware('auth');
```

Use `404` rather than `403` on the ownership check. A `403` confirms the resource exists, which is an enumeration primitive you have no reason to hand out.

## 5. Bulk operations and mass assignment of tenant_id

Two related failures.

Bulk updates skip model events and, depending on how your scope is written, can skip the scope too:

```php
Invoice::whereIn('id', $request->input('ids'))->update(['status' => 'paid']);
```

If those IDs came from the request, the user chose them. Unless the scope constrains this query, a user can mark another tenant's invoices as paid by submitting their IDs.

The other half is `tenant_id` being fillable:

```php
protected $fillable = ['name', 'amount', 'tenant_id'];   // the last one is a mistake
```

Now `Invoice::create($request->validated())` lets a user assign their record to a different tenant if the field reaches the payload. `tenant_id` should be set by your application and never by user input, which means keeping it out of `$fillable` entirely and assigning it explicitly. The general shape of this failure is covered in [Laravel mass assignment](/blog/laravel-mass-assignment-fillable-guarded).

## Test it, because review will not catch it

Every example above looks fine in a pull request. That is the actual problem. Reviewers see a scoped model and assume the scope holds, and the failures are in the gaps between the scope and everything else.

What catches them is a test suite that treats isolation as an invariant:

```php
public function test_tenant_cannot_read_another_tenants_invoice(): void
{
    $a = Tenant::factory()->create();
    $b = Tenant::factory()->create();
    $invoice = Invoice::factory()->for($b)->create();

    $this->actingAs(User::factory()->for($a)->create())
        ->get("/invoices/{$invoice->id}")
        ->assertNotFound();
}
```

Write one per resource, and add one to the definition of done for any new resource. It is repetitive and it is the only thing that reliably works, because isolation regressions arrive through ordinary feature work rather than through anything that looks like a security change.

Then run the same exercise against your queues and your exports, which is where the tests almost never reach and where the highest-volume leaks live. A report emailed to the wrong customer exposes far more rows than a single mis-scoped page ever will.

None of this is visible from outside, which is why it needs to live in your test suite rather than in a scan. StackShield covers the configuration and exposure half of the problem, the part that is visible externally, and [a free scan](/free-scan) will tell you where you stand on that side while your test suite handles this one.

---

## Frequently Asked Questions

### Why do security scanners miss tenant isolation bugs?

Because isolation is a property of your business logic, not of your configuration or your dependencies. An external scanner sees a login page, a set of headers, and a framework version. It has no way to know that account A should never see account B's records, because that rule exists only in your domain. Finding these bugs requires authenticated testing with two accounts and knowledge of what the boundary is supposed to be.

### Are global scopes enough to enforce tenant isolation in Laravel?

They cover the common path and leak in several specific places. Global scopes apply to Eloquent queries, so anything bypassing Eloquent bypasses them: raw DB queries, joins onto unscoped tables, and query builder calls that do not go through the model. They also do not apply where there is no authenticated tenant context, which is the situation inside queued jobs and scheduled commands.

### How do queued jobs break tenant isolation?

A job runs in a worker process with no session and no authenticated user, so anything that resolves the current tenant from auth returns null there. If your global scope depends on that context, it either fails or silently applies no filter, which turns a scoped query into an unscoped one. The fix is to pass the tenant identifier explicitly into the job payload and re-establish context at the start of handle, never to infer it.

### What is the safest way to structure multi-tenant data in Laravel?

Separate databases or schemas per tenant give the strongest isolation, since a query cannot cross a boundary that does not exist in the connection. The cost is operational: migrations, connection management, and cross-tenant reporting all get harder. A shared database with a tenant column is far more common and perfectly workable, but it makes isolation a discipline you enforce continuously rather than a property you get for free.

### How do you test for tenant isolation bugs?

Create two tenants with real data in an automated test, then attempt every read and write path as tenant A against tenant B's identifiers, asserting a 403 or 404 rather than a 200. Make it a test suite rather than a manual exercise, because isolation regressions are introduced by ordinary feature work and will not be caught by anything that runs less often than your CI pipeline.

