The Core Problem: Shared State Across Tenants
Most multi-tenant Laravel applications resolve the current tenant early in the request lifecycle and stash it somewhere — a singleton, a static property, or a config value. That works until you run queued jobs, use Octane, or introduce parallel test execution. Shared state leaks.
The fix is to treat the tenant as a scoped service: resolved once per HTTP request (or job), then discarded. Laravel's container has supported scoped() bindings since v8, but few teams use them deliberately for tenancy.
Registering a Scoped Tenant Binding
// AppServiceProvider::register()
$this->app->scoped(CurrentTenant::class, function () {
// Intentionally empty — resolved by middleware, not here.
return new NullTenant();
});
scoped() behaves like singleton() within a single request lifecycle, but the container flushes it automatically when $app->forgetScopedInstances() is called — which Octane does between requests.
Resolving the Tenant in Middleware
final class ResolveTenantMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$host = $request->getHost();
$tenant = Tenant::where('domain', $host)->firstOrFail();
// Rebind the scoped instance with the real tenant.
$this->app->instance(CurrentTenant::class, $tenant);
return $next($request);
}
}
Because instance() overwrites the scoped binding for this request only, every subsequent app(CurrentTenant::class) call in controllers, actions, and Eloquent observers gets the correct tenant without any static state.
Scoping Eloquent Queries Automatically
Instead of sprinkling where('tenant_id', ...) everywhere, attach a global scope driven by the container:
final class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$tenant = app(CurrentTenant::class);
if ($tenant instanceof NullTenant) {
return; // CLI / queue context without a tenant.
}
$builder->where($model->getTable() . '.tenant_id', $tenant->id);
}
}
Register it on every tenant-aware model via a trait:
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope());
static::creating(function (Model $model): void {
$model->tenant_id ??= app(CurrentTenant::class)->id;
});
}
}
Keeping Filament Panels Tenant-Aware
Filament v3/v4 supports a tenant() configuration on panels, but you still need the container binding to be correct before Filament resolves resources. Register ResolveTenantMiddleware in your panel's middleware() array before Filament's own middleware:
->middleware([
ResolveTenantMiddleware::class,
...Filament::getDefaultMiddleware(),
])
Then in any Filament resource, inject CurrentTenant via the constructor or app() — the scoped binding guarantees you get the request's tenant, not a stale one from a previous request.
Queue Jobs: Explicitly Passing Tenant Context
Scoped bindings are not preserved across queue boundaries. Serialize the tenant ID into the job and re-bind inside handle():
final class ProcessInvoiceJob implements ShouldQueue
{
public function __construct(
private readonly int $tenantId,
private readonly int $invoiceId,
) {}
public function handle(CurrentTenant $current): void
{
$tenant = Tenant::findOrFail($this->tenantId);
app()->instance(CurrentTenant::class, $tenant);
// All Eloquent queries inside this job are now scoped.
Invoice::findOrFail($this->invoiceId)->process();
}
}
This pattern is explicit, testable, and avoids the "tenant bleeds into the next job" bug that plagues singleton-based approaches.
Takeaways
- Use
scoped()bindings for tenant context — they reset automatically in Octane and test isolation. - Overwrite the scoped instance with
app()->instance()in middleware, not in a service provider. - Drive Eloquent global scopes from the container, not from static properties.
- Never rely on scoped bindings surviving queue serialization — pass the tenant ID explicitly.
- Register tenant middleware before Filament's stack to ensure resources see the correct tenant.