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

Multi-Tenant SaaS in Laravel: Isolating Tenant State with Scoped Singletons

3 min read Mohamed Said Mohamed Said

The Core Problem: Shared State Across Tenants

In a multi-tenant Laravel application, the most dangerous bug is not a 500 — it is Tenant A silently reading Tenant B's data. This happens when tenant context leaks through long-lived singletons, static properties, or carelessly bound services.

The fix is not just global scopes on every model. It starts earlier: resolving and scoping tenant identity at the container level, so every service that depends on tenant context receives the right instance for the current request.

Resolving the Current Tenant

Start with a dedicated TenantContext value object and a resolver that runs early in the request lifecycle.

// app/Tenant/TenantContext.php
final readonly class TenantContext
{
    public function __construct(
        public readonly int $id,
        public readonly string $slug,
        public readonly string $dbConnection,
    ) {}
}
// app/Tenant/TenantResolver.php
final class TenantResolver
{
    public function fromRequest(Request $request): TenantContext
    {
        $host = $request->getHost(); // e.g. acme.app.test
        $slug = explode('.', $host)[0];

        $tenant = Cache::remember("tenant:{$slug}", 60, fn () =>
            Tenant::where('slug', $slug)->firstOrFail()
        );

        return new TenantContext(
            id: $tenant->id,
            slug: $tenant->slug,
            dbConnection: "tenant_{$tenant->id}",
        );
    }
}

Binding as a Scoped Singleton

Laravel's scoped() binding was designed for exactly this: a singleton that is reset on every request (and on every Octane request cycle).

// app/Providers/TenantServiceProvider.php
public function register(): void
{
    $this->app->scoped(TenantContext::class, function () {
        // Resolved lazily on first use within the request
        throw new RuntimeException('TenantContext must be set before use.');
    });
}

Then in a middleware, replace the binding with the real value:

// app/Http/Middleware/SetTenantContext.php
public function handle(Request $request, Closure $next): Response
{
    $context = app(TenantResolver::class)->fromRequest($request);

    // Rebind the scoped singleton for this request
    app()->instance(TenantContext::class, $context);

    // Switch the DB connection so all Eloquent queries use the tenant DB
    config(['database.default' => $context->dbConnection]);
    DB::purge($context->dbConnection);

    return $next($request);
}

Register this middleware early in bootstrap/app.php (Laravel 11+):

->withMiddleware(function (Middleware $middleware) {
    $middleware->prependToGroup('web', SetTenantContext::class);
    $middleware->prependToGroup('api', SetTenantContext::class);
})

Consuming Tenant Context Downstream

Any service that needs tenant-aware behaviour simply type-hints TenantContext:

final class BillingService
{
    public function __construct(
        private readonly TenantContext $tenant,
        private readonly StripeClient $stripe,
    ) {}

    public function currentBalance(): int
    {
        return Cache::tags(["tenant:{$this->tenant->id}"])
            ->remember('balance', 300, fn () =>
                $this->stripe->balance($this->tenant->id)
            );
    }
}

Because TenantContext is a scoped singleton, the container always injects the request's resolved instance — no static calls, no app() inside the service.

Octane Safety

Under Octane (Swoole/RoadRunner), workers persist between requests. scoped() bindings are flushed automatically at the start of each Octane request cycle via ScopeMiddleware, but you must also:

  • Never store tenant state in static properties on services.
  • Purge DB connections explicitly (as shown above) — Octane does not reset config() between requests.
  • Tag caches with tenant ID rather than relying on key prefixes alone.
// In your Octane config, ensure scoped bindings are flushed:
// config/octane.php
'flush' => [
    // Octane flushes scoped() automatically, but list any
    // additional singletons that hold per-request state:
    BillingService::class,
],

Takeaways

  • Use app()->scoped() for any service that carries per-tenant state; it resets automatically each request and each Octane cycle.
  • Resolve tenant identity in middleware and rebind with app()->instance() — keep the resolver itself stateless.
  • Type-hint TenantContext in downstream services; avoid app() or static helpers inside business logic.
  • Tag caches with tenant IDs and purge DB connections explicitly when switching contexts.
  • Audit for static properties on long-lived services before deploying under Octane.

Found this useful?

Frequently Asked Questions

3 questions
Q01 What is the difference between `singleton()` and `scoped()` in Laravel's container?
A `singleton()` binding is resolved once and reused for the entire application lifetime — dangerous in long-lived processes. A `scoped()` binding is also resolved once, but it is flushed and re-resolved at the start of each HTTP request or Octane request cycle, making it safe for per-request state like tenant context.
Q02 Do I still need Eloquent global scopes if I switch the database connection per tenant?
If each tenant has a completely separate database, switching the connection is sufficient for data isolation. Global scopes are still useful when tenants share a single database and rows are discriminated by a `tenant_id` column — they act as a safety net against accidentally unscoped queries.
Q03 How do I handle background jobs that need tenant context?
Serialize the `TenantContext` (or just the tenant ID) onto the job payload. In the job's `handle()` method, resolve and rebind the context before executing business logic, then switch the DB connection the same way the middleware does. Never rely on the context being set from a previous request.

Continue reading

More Articles

View all