Multi-Tenant SaaS in Laravel: Isolating Tenant State with Scoped Service Bindings
#laravel #multi-tenant #saas #filament #architecture

Multi-Tenant SaaS in Laravel: Isolating Tenant State with Scoped Service Bindings

3 min read Mohamed Said Mohamed Said

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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 What is the difference between scoped() and singleton() in Laravel's container?
Both resolve the binding once and cache the result, but scoped() instances are flushed when forgetScopedInstances() is called — which happens automatically between Octane requests and can be triggered manually in tests. Singletons persist for the entire process lifetime.
Q02 How do I prevent the TenantScope from breaking artisan commands that run without a tenant?
Return early from the scope's apply() method when the resolved binding is a NullTenant (or any sentinel value). Commands and jobs that don't call app()->instance(CurrentTenant::class, $tenant) will receive the NullTenant registered in the service provider, and the scope will be skipped.
Q03 Can I use this pattern with Filament's built-in tenancy support?
Yes. Filament's panel tenancy and this scoped-binding approach are complementary. Filament handles UI routing and resource filtering; the scoped binding ensures that any code outside Filament's resource layer — jobs, actions, observers — also has access to the correct tenant without additional plumbing.

Continue reading

More Articles

View all