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

Simulator · container bindings

Every instance and every lifetime runs on the tested reducer, measured against illuminate/container 13.31 on PHP 8.4.

In request 2, bound as singleton. The last resolve in request 1 returned instance 1 carrying user null. The response went out, and the process is still running. The container is exactly as it was, singleton and all.

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 RequestState and gets the same object back, still carrying alice. 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 with user: 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/container 13.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

Spotted a problem, or have a way to make this clearer? Suggest an improvement.