Global Scopes for Multi-Tenant Laravel Apps, and Where They Leak
How to scope tenant data with an Eloquent global scope in Laravel 12, and the places it does not reach, queued jobs, console commands, withoutGlobalScopes, validation rules, raw queries and pivot tables.

Global Scopes for Multi-Tenant Laravel Apps
In a single-database SaaS every tenant's rows sit in the same tables, separated by a
tenant_id column. Every query has to filter on it. Forget once, in one report or one
autocomplete endpoint, and one shop sees another shop's customers.
An Eloquent global scope is the usual answer: add the filter once, on the model, and every query gets it. It works well, and I use it. But a global scope only covers Eloquent queries made while the app knows who the current tenant is, and several common code paths fall outside that. This post covers a setup that works and then the places it leaks.
Where the current tenant comes from
The first decision matters more than the scope itself. A lot of examples read the tenant
inside the scope with auth()->user()->tenant_id. I don't, for two reasons:
- Queued jobs and console commands have no authenticated user, so the scope silently stops filtering there.
- If the
Usermodel is itself tenant-scoped, the scope callsauth()->user(), which queriesUser, which applies the scope, which callsauth()->user(). That loop is a classic.
A safer setup keeps the current tenant in one small object bound as a scoped singleton, set by middleware for web requests and set explicitly everywhere else:
namespace App\Support;
use App\Models\Tenant;
final class CurrentTenant
{
private ?Tenant $tenant = null;
public function set(?Tenant $tenant): void
{
$this->tenant = $tenant;
}
public function get(): ?Tenant
{
return $this->tenant;
}
public function id(): ?int
{
return $this->tenant?->id;
}
}
// AppServiceProvider::register()
$this->app->scoped(CurrentTenant::class);
scoped gives one instance per request or per job, and Laravel resets it between jobs on a
long-running worker, so one job's tenant does not bleed into the next.
The middleware resolves the tenant however your app identifies it (subdomain, custom domain, the user's current team) and sets it:
public function handle(Request $request, Closure $next): Response
{
$tenant = Tenant::query()->where('domain', $request->getHost())->firstOrFail();
app(CurrentTenant::class)->set($tenant);
return $next($request);
}
The scope
php artisan make:scope TenantScope puts the class in app/Models/Scopes. The scope implements
Illuminate\Database\Eloquent\Scope and its one method, apply:
namespace App\Models\Scopes;
use App\Support\CurrentTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$tenantId = app(CurrentTenant::class)->id();
if ($tenantId === null) {
$builder->whereRaw('1 = 0');
return;
}
$builder->where($model->qualifyColumn('tenant_id'), $tenantId);
}
}
Two details. qualifyColumn prefixes the table name, so the condition stays unambiguous when
the query joins another tenant table that also has tenant_id. And when no tenant is set, the
scope returns nothing instead of everything. Many examples do if ($tenantId) { ... } and skip
the filter when there is no tenant, which means the failure mode of "we forgot to set the
tenant" is "show all tenants' data". I would rather get an empty table and a bug report.
Attaching it to models
Laravel 12 lets you attach a global scope with the ScopedBy attribute, from
Illuminate\Database\Eloquent\Attributes\ScopedBy. Tenant models also need tenant_id filled
on create, so I pair the attribute with a small trait:
namespace App\Models\Concerns;
use App\Models\Tenant;
use App\Support\CurrentTenant;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
static::creating(function ($model): void {
$model->tenant_id ??= app(CurrentTenant::class)->id()
?? throw new \LogicException('Creating '.$model::class.' without a current tenant.');
});
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
use App\Models\Concerns\BelongsToTenant;
use App\Models\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Model;
#[ScopedBy([TenantScope::class])]
class Customer extends Model
{
use BelongsToTenant;
}
You can also register the scope inside the trait's boot method with
static::addGlobalScope(new TenantScope), which saves repeating the attribute. I like the
attribute because it is visible at the top of the model: anyone opening Customer sees that
it is tenant-scoped without reading a trait. Both are in the
Eloquent docs.
Route model binding goes through Eloquent, so /customers/{customer} with another tenant's ID
returns a 404. That alone closes a lot of IDOR bugs.
Leak 1: queued jobs and console commands
A job is handled in a worker process. The middleware never ran there, so CurrentTenant is
empty and every scoped query in the job returns nothing (with the fail-closed scope) or
everything (with the common fail-open one).
Models passed to the job still come back: when Laravel restores a serialized model it queries without global scopes. It's the queries the job runs afterwards that need the tenant.
Laravel's Context is a clean way to carry the tenant
across. Context data is captured when a job is dispatched and hydrated back when the job runs.
Add the tenant ID as hidden context, so it doesn't end up in every log line, and restore it in
a hydrated callback:
// In the tenant middleware, after resolving the tenant
Context::addHidden('tenant_id', $tenant->id);
// AppServiceProvider::boot()
use Illuminate\Log\Context\Repository;
Context::hydrated(function (Repository $context): void {
if ($context->hasHidden('tenant_id')) {
app(CurrentTenant::class)->set(Tenant::find($context->getHidden('tenant_id')));
}
});
The docs ask you to change the repository passed to the callback rather than the Context
facade, which is why this reads from $context.
Console commands and scheduled tasks have no request at all, so they loop over tenants and set the tenant explicitly for each one:
Tenant::query()->each(function (Tenant $tenant): void {
app(CurrentTenant::class)->set($tenant);
SendOverdueInvoiceReminders::dispatch();
});
The dispatch picks up the tenant from the code that set it, as long as the command also adds it
to Context. In practice, put both lines in one TenantManager::switchTo($tenant) method so they
can't drift apart.
Leak 2: withoutGlobalScopes removes more than you think
Admin reports and platform-level jobs legitimately need cross-tenant data. The tempting call
is withoutGlobalScopes(), with no arguments. That removes every global scope on the model,
including SoftDeletes. A "total customers across all tenants" report written that way counts
deleted customers too.
Remove only the scope you mean to remove:
Customer::withoutGlobalScope(TenantScope::class)->count();
Or, if a model has several custom scopes and you want to keep a known set,
withoutGlobalScopesExcept([SoftDeletingScope::class]).
Don't call this from a controller directly either. Keep cross-tenant queries in a few named
places, an Admin namespace or a dedicated query class, so a grep for TenantScope::class
shows every bypass in the app. If you find yourself adding a bypass in a tenant-facing feature
(sharing a document with another workspace, say), model that sharing explicitly with its own
table rather than punching through the scope.
Leak 3: validation rules don't use Eloquent
Rule::unique and Rule::exists check the database with the query builder, not through your
model. Global scopes don't apply to them. This one is easy to miss because the rules look
model-aware:
'email' => ['required', 'email', Rule::unique(Customer::class)],
'category_id' => ['required', Rule::exists(Category::class, 'id')],
The first rejects an email that exists in any tenant, which is wrong for most apps: two shops can both have a customer with the same email. The second is worse. It accepts a category ID from another tenant, and the record gets saved pointing at someone else's data.
Add the tenant condition yourself:
$tenantId = app(CurrentTenant::class)->id();
'email' => [
'required', 'email',
Rule::unique(Customer::class)->where('tenant_id', $tenantId)->ignore($this->customer),
],
'category_id' => [
'required',
Rule::exists(Category::class, 'id')->where('tenant_id', $tenantId),
],
The database should agree with the rule. A unique index on email alone fights this
validation; the index has to include the tenant:
$table->unique(['tenant_id', 'email']);
Every tenant table also wants an index that starts with tenant_id, because every single query
now filters on it. The composite unique index covers that for the email lookups; for the
rest, add $table->index(['tenant_id', 'created_at']) or whatever your list pages sort by.
Leak 4: anything that is not an Eloquent query on a scoped model
The scope lives on the model. Anything that does not go through that model's builder skips it:
DB::table('customers')and raw SQL.- Joins.
Order::query()->join('customers', ...)scopesorders, but the joinedcustomersrows are not filtered. If the join can only match same-tenant rows through the foreign key, that is fine. If it can't, add the condition to the join. - Pivot tables. In a
belongsToMany, the related model's scope applies, but the pivot rows themselves are not scoped. If the pivot carries tenant-sensitive data, give it a model withusing()and scope that, or make sure both ends are always same-tenant. - Models that forgot the attribute. This is the most common one. A new
Couponmodel gets created, nobody addsScopedBy, and it is global.
The last one is easy to catch with an architecture test. Pest's arch plugin can check that classes use
a trait; for the attribute, loop over models with tenant_id and assert the scope is
registered:
use App\Models\Scopes\TenantScope;
use Illuminate\Support\Facades\Schema;
it('scopes every model that has a tenant_id column', function (string $model) {
$instance = new $model;
if (! Schema::hasColumn($instance->getTable(), 'tenant_id')) {
expect(true)->toBeTrue();
return;
}
expect($instance->hasGlobalScope(TenantScope::class))->toBeTrue();
})->with(fn () => collect(glob(app_path('Models/*.php')))
->map(fn (string $path) => 'App\\Models\\'.basename($path, '.php'))
->all());
Testing isolation directly
Beyond the structural test, keep one behavioral test per sensitive model:
it('does not show another tenant\'s customers', function () {
$mine = Tenant::factory()->create();
$theirs = Tenant::factory()->create();
app(CurrentTenant::class)->set($theirs);
$foreign = Customer::factory()->create();
app(CurrentTenant::class)->set($mine);
expect(Customer::find($foreign->id))->toBeNull();
$this->actingAs(User::factory()->for($mine)->create())
->get(route('customers.show', $foreign))
->assertNotFound();
});
When a global scope is not enough
Global scopes are the right tool for the kind of app I build most, one database, shared tables, many tenants of similar size.
If tenants need hard isolation for contractual reasons, or a single tenant is large enough to need its own database, a column filter is the wrong layer and you want database-per-tenant. At that point a package such as stancl/tenancy handles the connection switching, and the scope goes away. Short of that, a fail-closed scope, a tenant that follows jobs through Context, and tenant-aware validation rules cover the places where data usually leaks.