HTTP & container
What singleton means when the process does not die
The mistake
bind gives you a new instance every time. singleton gives you one instance.
That is the whole model most people carry, and for years it was good enough,
because there was a question it never had to answer: one instance for how long.
The container lives as long as the process does. Under a normal PHP request that is one request: the process starts, boots the container, answers, and dies, so a singleton cannot outlive the request that made it. Start serving from a process that stays alive, and the same word now means something much bigger. The container persists, your singleton persists, and anything request-shaped inside it persists too, straight into the next person’s request.
The machine
Every instance and every lifetime runs on the tested reducer, measured against illuminate/container 13.31 on PHP 8.4.
Drive it
The panel has already served request one: it resolved a RequestState, wrote
alice into it, and ended. The process is still running.
- Press “Resolve it”. Request two asks the container for a
RequestStateand gets the same object back, still carryingalice. Nothing threw. Nothing was logged. The wrong user is on the page. - Press the featured button to switch to
scoped. Run it again: request two gets a new instance withuser: null. One word. - Turn off “process stays alive” and run the singleton again. No leak, because the container died with the request. This is why the bug does not exist in development and does exist in production.
The mechanism
Three registrations, three lifetimes.
bind stores a factory. Every make() runs it again, so every resolve is a new
object. Two resolves in the same request give two different instances, so
anything you write onto the first one is not on the second.
singleton stores the same factory, then keeps the first result in the
container’s instances array and returns it from then on. It lives as long as
the container does.
scoped is a singleton with a marker on it. It goes in the same instances array,
and its name is also added to a list of scoped bindings. When the framework
finishes a unit of work it calls forgetScopedInstances(), which drops exactly
those and leaves the rest alone. That marker is the whole difference between
scoped and singleton.
So the question is how long. For bind the answer is a single resolve. For the
other two the runtime decides it: how long the container lives, and what calls
forgetScopedInstances() between units of work. Two runtimes stay alive between
units of work and reset the scope: Octane between requests, and the queue worker
between jobs. The worker is the one that catches people, because almost every
Laravel app in production runs one, with or without Octane.
There is a second version of the same trap, one level down. A singleton captures its dependencies when it is built, not when it is used:
$this->app->bind(RequestState::class, fn () => new RequestState());
$this->app->singleton(Dashboard::class);
Dashboard is resolved once. The RequestState it was constructed with is
frozen inside it forever, even though RequestState is bound to give a fresh
instance every time. Resolving RequestState on its own afterwards gives a
different object from the one the dashboard is holding. The binding is correct
and the thing holding it is stale.
In your code
The rule that covers almost every case:
// stateless, cheap to build: bind
// (a concrete class resolves this way unregistered too; this says so out loud)
$this->app->bind(SlugGenerator::class);
// stateless and expensive, or genuinely global: singleton
$this->app->singleton(HttpClient::class);
// anything holding the current user, request, tenant or locale: scoped
$this->app->scoped(TenantContext::class);
If a class has a property that answers “who is this for”, it is scoped, not a singleton. Auth state, the current tenant, a request id, a per-request cache, a collected list of anything.
The test that catches it is worth writing once, because it does not need Octane to run:
$first = app(TenantContext::class);
$first->tenant = 'acme';
app()->forgetScopedInstances();
$this->assertNull(app(TenantContext::class)->tenant);
That is exactly what the worker does between jobs, and it fails loudly the moment
someone changes scoped to singleton.
The fine print
- The lifetimes were measured against
illuminate/container13.31 on PHP 8.4, holding a reference to every instance. Comparing object ids without holding the objects gives a false “same instance”, because PHP reuses the id of an object it has freed. The first run of that check said scoped survived the boundary, and it was the measurement that was wrong, not the container. - Contextual binding sits alongside all this:
$this->app->when(Reporter::class)->needs(Clock::class)->give(Utc::class)gives one consumer a different implementation. It changes what you get, not how long it lives. - A concrete class with no binding at all still resolves, by reflection, as a new
instance each time. Not registering something is the same lifetime as
bind. - Octane also resets other state between requests and can be told to flush more. The framework’s own singletons are already handled; it is application code that needs the attention.
- Facades resolve through the same container, so a facade over a leaky singleton
leaks in exactly the same way.
Facade::clearResolvedInstances()is part of the same reset the queue worker runs.
Further reading
- Laravel: the service container covers
bind,singleton,scopedand contextual binding, with the resolution rules. - Laravel Octane: dependency injection and Octane is the page that spells out which container bindings leak between requests.
- Laravel: queues explains that a worker is a long-lived process, which is the same reason this applies without Octane.
Spotted a problem, or have a way to make this clearer? Suggest an improvement.