Scoped bindings — per-request singletons in Octane
Concept
Scoped bindings are a Laravel feature designed specifically for long-lived PHP processes — most notably Laravel Octane (which runs on Swoole or RoadRunner). In a traditional PHP-FPM setup, every request is a fresh process: singletons are safe because they die with the request. In Octane, a single worker process handles thousands of requests sequentially without restarting. A singleton() binding created during bootstrap persists across all those requests, which is dangerous if the singleton holds request-specific state (the authenticated user, the current tenant, a database connection scoped to a transaction).
scoped(string $abstract, Closure|string $concrete) solves this by creating a "per-request singleton": the first make() call within a request builds and caches the instance in $this->instances, exactly like a regular singleton. But at the end of the request, Octane calls Application::forgetScopedInstances(), which flushes all scoped bindings from $this->instances — leaving the $bindings recipe intact so the next request can build a fresh one.
Internally, scoped() is almost identical to singleton(). The difference is that the abstract is additionally appended to Application::$scopedInstances (an array on the Application subclass, not the base Container). forgetScopedInstances() iterates $scopedInstances and calls unset($this->instances[$abstract]) for each. The binding stays in $this->bindings so the container can still resolve it.
The practical rule is straightforward: anything that must be a singleton within a request but must reset between requests should be scoped(). Examples: the authenticated User, a TenantContext value object, a per-request correlation ID service, or a database transaction manager.
| Binding method | Lifetime | Octane safe |
|---|---|---|
bind() | New instance every make() | Yes (no state cached) |
singleton() | Entire process lifetime | Only if truly stateless |
scoped() | Single request, then flushed | Yes |
instance() | Until explicitly unset | Depends |
Code Example
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Services\TenantContext;
use App\Services\RequestCorrelationId;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// scoped(): singleton within a request, flushed by Octane between requests.
// Safe: each request gets a fresh TenantContext resolved from the JWT/session.
$this->app->scoped(TenantContext::class, function (): TenantContext {
return TenantContext::fromRequest(request());
});
// scoped(): per-request correlation ID — changes every request.
$this->app->scoped(RequestCorrelationId::class, function (): RequestCorrelationId {
return new RequestCorrelationId(
request()->header('X-Correlation-Id') ?? \Illuminate\Support\Str::uuid()->toString()
);
});
// singleton(): safe because it holds no request state.
// A stateless HTTP client wrapper — safe across all requests.
$this->app->singleton(\App\Services\HttpClient::class, function (): \App\Services\HttpClient {
return new \App\Services\HttpClient(config('services.timeout'));
});
// bind(): new instance on every make() — for objects that SHOULD not be shared.
$this->app->bind(\App\Dto\OrderSummary::class, fn() => new \App\Dto\OrderSummary());
}
}
// --- What Octane does between requests ---
// In Illuminate\Foundation\Application::forgetScopedInstances():
//
// foreach ($this->scopedInstances as $scoped) {
// unset($this->instances[$scoped]);
// }
//
// This means TenantContext and RequestCorrelationId are cleared.
// The next request calls make() → finds no instance → calls build() → fresh object.
// --- Detecting Octane in code ---
// If you need to conditionally register scoped vs singleton:
$this->app->scoped(TenantContext::class, fn() => TenantContext::fromRequest(request()));
// --- Testing scoped bindings ---
// In a test, scoped bindings behave like singletons within a single test case.
// If you want to simulate a "second request", call forgetScopedInstances() manually:
class TenantContextTest extends \Tests\TestCase
{
public function test_tenant_context_resets_between_requests(): void
{
config(['app.tenant' => 'acme']);
$first = $this->app->make(TenantContext::class);
// Simulate Octane request flush
$this->app->forgetScopedInstances();
config(['app.tenant' => 'globex']);
$second = $this->app->make(TenantContext::class);
$this->assertNotSame($first, $second);
$this->assertSame('globex', $second->name());
}
}Interview Q&A
Q: What is a scoped binding in Laravel, and why does it exist?
A scoped binding is registered with Application::scoped() and behaves as a singleton within a single request: once resolved, the instance is cached in $this->instances and reused for the duration of that request. At the end of the request, Application::forgetScopedInstances() removes the instance from $this->instances, so the next request gets a freshly built object. This exists primarily for Laravel Octane, where the process is long-lived and a standard singleton() would persist state across requests. Examples of appropriate scoped bindings are: the authenticated user object, a per-request tenant context, or a correlation ID service. A singleton binding for these would cause data leakage between requests in an Octane environment.
Q: How does Application::forgetScopedInstances() work internally, and what does it leave intact?
forgetScopedInstances() iterates the $scopedInstances array — populated by scoped() with each registered abstract — and calls unset($this->instances[$scoped]) for each. This removes the cached object from the instance store but does not touch $this->bindings. The factory closure or class name registered during register() remains in $bindings, so the next make() call can build a fresh instance using the same recipe. Only the resolved object is forgotten, not the blueprint for creating it. This design lets scoped bindings work transparently: from the calling code's perspective, it always calls app(TenantContext::class) and receives a properly initialized object, whether or not it is the same object as the previous request.
Q: How do you identify which of your existing singletons are unsafe for Octane, and what is the migration path?
A singleton is Octane-unsafe if it captures request-specific state during its construction or first use: references to request(), auth()->user(), session data, or anything that differs between requests. The migration path is to change singleton() to scoped() for those bindings — that is all. Laravel will then flush them between requests automatically. You can also run php artisan octane:install, which generates an octane config with a warm array (services to pre-resolve) and a flush array (services to flush). Additionally, the laravel/octane package provides a #[Singleton(onRequest: true)] attribute as an alternative notation. During review, search for singleton() registrations in service providers and ask: "can this object's constructor reference the current request or auth state?" If yes, it should be scoped().