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
TenantContextin downstream services; avoidapp()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.