Laravel Singleton Under Octane — User Data Leak & Fix
User A's cart leaked to User B under Laravel Octane - singleton held auth state.
- Auto-resolution: the container inspects constructor type-hints via ReflectionClass and recursively resolves every dependency
- Binding types: bind() (new every time), singleton() (once per container), scoped() (once per request/job)
- Contextual binding: same interface, different implementations per consumer class
- extend(): transparently wraps resolved instances with decorators (caching, logging, circuit-breaking)
- singleton() + request state = data leak between users in Octane
- Use scoped() for anything that touches per-request context
- Binding concrete classes to themselves instead of interfaces. Auto-resolution bypasses your binding, and test swaps silently fail.
Imagine a huge restaurant kitchen. When a waiter needs a dish, they don't go hunting for ingredients, cook the meal, and plate it themselves — they just call out the order and the kitchen hands it over, perfectly prepared. The Laravel Service Container is that kitchen. Your code says 'I need a PaymentGateway' and the container builds it, with all its dependencies already assembled, no manual construction needed.
Every non-trivial Laravel application eventually hits the same wall: classes that depend on other classes, that depend on yet more classes. Wire them all together by hand and you end up with constructor chains so deep they'd make a plumber wince. Swap one implementation — say, from Stripe to PayPal — and you're touching a dozen files.
The Service Container is Laravel's answer. Formally it's an IoC (Inversion of Control) container — a registry that knows how to build any object your application asks for, injecting its dependencies automatically. Instead of your code reaching out and constructing collaborators, the container pushes them in. That inversion is why testing becomes trivial: swap a real HTTP client for a fake one in two lines, no rewiring required.
By the end of this article you'll understand exactly how the container resolves classes, when to use bind vs singleton vs scoped, how contextual binding lets you serve different implementations to different consumers, and the performance and lifecycle gotchas that bite teams in production.
How the Container Actually Resolves a Class — Under the Hood
When you type-hint a dependency in a controller constructor, you're trusting the container to build it. But how? Laravel uses PHP's ReflectionClass API to inspect the constructor, discover each parameter's type-hint, recursively resolve each dependency, then instantiate the class with everything wired up. This happens automatically for any concrete class — no registration needed.
This auto-resolution ('autowiring') is why so much of Laravel 'just works'. The container walks the entire dependency graph. If OrderService needs PaymentProcessor, which needs HttpClient, which needs GuzzleClient — it resolves all of them in one call.app()->make(OrderService::class)
The cost is real though. Every reflection call carries overhead. For hot paths — middleware that runs on every request, for example — the container uses a resolved-instance cache ($this->resolved and $this->instances on the Illuminate\Container\Container class). Once a singleton is built, it's returned from memory on every subsequent resolution. Understanding this distinction between 'build every time' and 'build once' is what separates advanced container usage from beginner usage.
- ReflectionClass inspects the constructor at runtime — no compile-time wiring.
- Each parameter's type-hint triggers a recursive
make()call. - Resolved singletons are cached in $this->instances — subsequent calls skip reflection.
- Cyclic dependencies (A needs B, B needs A) cause infinite recursion with no guard.
- Auto-resolution works for any concrete class — no
bind()call needed.
bind vs singleton vs scoped — Choosing the Right Lifecycle
The binding method you choose controls how long an instance lives. Get this wrong in production and you'll either waste memory building objects repeatedly or — far worse — share mutable state between requests, causing subtle, impossible-to-reproduce bugs.
bind creates a new instance every single time the container resolves the abstract. Use it for stateful, request-specific objects where sharing would corrupt data — like a shopping cart builder that accumulates line items.
singleton builds the object once per container lifetime and caches it. In a standard HTTP request lifecycle that's effectively 'once per request'. But in a long-running process — Laravel Octane, queue workers, scheduled commands — singletons persist across multiple jobs or requests. This is where teams get burned.
scoped (introduced in Laravel 8) is the sweet spot for Octane: it behaves like a singleton within a single request or job, then gets flushed automatically when that lifecycle ends. It's the answer to 'I want singleton performance without the cross-request contamination'.
Contextual binding adds another dimension: the same interface can resolve to different concrete classes depending on which class is asking. An EmailNotifier and a SmsNotifier can both implement NotifierInterface, and you tell the container which to inject into which consumer — no if statements in business logic.
- bind(): new instance every resolution. Safe but slower. Use for stateful per-call objects.
- singleton(): one instance per container. Fast but dangerous in Octane if stateful.
- scoped(): one instance per request/job. Auto-flushed. The correct default for Octane.
- Contextual binding: same interface, different implementation per consumer class.
- Tagged binding: group multiple implementations, resolve as a collection.
singleton() is per-process, not per-application — multi-process architectures (Horizon, Octane workers) each get their own copy. scoped() is the correct choice for any service that holds per-request state in Octane or per-job state in queue workers.Extending, Decorating & Resolving Events — Advanced Container Techniques
The container isn't just a factory — it's an event system. You can hook into the resolution lifecycle using , resolving()afterResolving(), and to decorate or mutate instances after they're built. This is how Laravel's own core adds behaviour without subclassing.extend()
extend() lets you wrap an already-registered binding. Every time the container builds the target, your closure receives the fresh instance and the container itself, and must return the final object. Use this to transparently wrap a service with a caching layer or a circuit breaker without touching the service's own code.
fires every time the container builds an instance of a type — even if it wasn't registered. This is perfect for initialisation work that shouldn't live in the constructor (setting a locale, attaching an observer, running a health check). resolving()afterResolving() fires after any resolving callbacks, giving you a post-init hook.
These hooks compose cleanly. A service can have multiple resolving callbacks registered by different service providers, and they all run in registration order. The pattern underpins how packages attach behaviour to your classes without requiring inheritance — it's open/closed principle in action.
- extend(): wraps an existing binding. Returns a new instance that delegates to the original.
- resolving(): fires on every resolution, even for unregistered classes. Good for init logic.
- afterResolving(): fires after all
resolving()callbacks. Good for audit logging. - Multiple
resolving()callbacks compose — they run in registration order. - extend() is the decorator pattern. Use it instead of inheritance for cross-cutting concerns.
extend() to wrap their PaymentGateway with a circuit breaker. The circuit breaker tracked failure counts in memory. Under Horizon with 4 worker processes, each process had its own circuit breaker instance with its own failure count. When Stripe had a partial outage, process 1 saw 5 failures and opened its circuit. But processes 2, 3, and 4 kept sending requests because their counters were at 0. The fix: store circuit breaker state in Redis (shared across processes) instead of in-memory. The decorator pattern via extend() was correct — the state storage was wrong.Testing With the Container — Swapping Real Services for Fakes
The container's real superpower only becomes obvious when you write tests. Because every dependency is injected rather than constructed internally, swapping a real implementation for a fake is a one-liner: . The container's app()->instance(PaymentGatewayInterface::class, $fakeGateway)instances array gets the fake, and every subsequent resolution returns it — for the rest of that test.
Laravel's inside a test also works, but app()->bind() is preferred when you already have a pre-built mock (common with PHPUnit's instance()createMock or Mockery). clears a specific singleton if you need to reset between sub-tests.app()->forgetInstance()
For Pest or PHPUnit feature tests, you can combine this with $this->swap() (a Laravel testing helper that calls instance and registers teardown cleanup automatically). This prevents test pollution — a critical concern when singletons persist across tests in a test suite that doesn't reboot the application between every test case.
The deeper point: the container makes your code testable by design, not by accident. Code that uses new inside a method is fundamentally harder to test — you can't intercept that construction. Container-resolved dependencies are always interceptable.SomeService()
- $this->swap(): sets the instance AND registers teardown cleanup. Preferred.
- app()->instance(): sets the instance but does NOT clean up. Causes test pollution.
- app()->forgetInstance(): manually clears a singleton between sub-tests.
- Always bind against interfaces, not concrete classes, for test swappability.
- Code that uses new
SomeService()inside a method is untestable. Container-resolved is always interceptable.
app()->instance() to swap in fakes. These 15 tests passed individually but 3 of them failed when run with the full suite. The issue: app()->instance() did not clean up after itself. Test #7 swapped in a fake PaymentGateway. Test #8 expected the real PaymentGateway but got the fake from test #7. The fix: replaced all app()->instance() calls with $this->swap(), which registers an after-test callback to flush the fake. All 200 tests passed consistently.app()->instance() in tests. swap() registers cleanup automatically; instance() causes test pollution that only manifests when tests run in specific order. Bind against interfaces, not concrete classes, to make swapping possible.Service Provider Lifecycle — register() vs boot() and Resolution Order
Service providers have two phases: register() and boot(). Understanding the difference is critical because putting resolution logic in the wrong phase causes 'Class not found' errors that only appear under certain provider load orders.
register() runs first, for all providers. This is where you bind things into the container. No other provider has registered yet, so you cannot safely call app()->make() for anything outside your own bindings.
boot() runs after ALL providers have completed their register() phase. This is where you use resolved services — configuring routes, registering event listeners, publishing assets, or calling app()->make() on dependencies provided by other providers.
The provider load order is determined by the providers array in config/app.php. Deferred providers (those implementing DeferrableProvider) only load when one of their provided services is actually requested. This is a performance optimization — a provider that only binds a rarely-used service should be deferred.
- register(): bind things. Do NOT resolve things from other providers.
- boot(): use things. All providers have registered by this point.
- Deferred providers: only load when their services are requested. Use for performance.
- provides(): required for deferred providers. Lists the services this provider offers.
- Provider order in config/app.php determines load order. Last provider wins for conflicting bindings.
app()->make(CacheInterface::class) in register() to configure a decorator. But CacheInterface was bound by the new package's provider, which hadn't run yet. The fix: move the decorator configuration to boot(), where all providers have completed registration. The intermittent nature (works locally, fails in production) was due to different provider load orders caused by different package discovery caches.boot() is for resolution. Never call app()->make() on external dependencies in register() — it creates a race condition with provider load order. Deferred providers reduce boot time but require a provides() method listing their services.Tagged Bindings & Service Locators — When Contextual Binding Isn't Enough
Contextual binding solves 'different implementation per consumer'. But sometimes a single consumer needs ALL implementations — a notification dispatcher that sends via email, SMS, and push simultaneously. Tagged bindings solve this.
$container->tag() groups multiple concrete classes under a tag name. $container->tagged() returns all of them as an array. This is cleaner than manually collecting implementations in a constructor and avoids the service locator anti-pattern where a class calls app()->make() for each implementation.
The key distinction from contextual binding: contextual binding gives ONE implementation to ONE consumer. Tagged binding gives ALL implementations to ONE consumer. Use contextual when the consumer needs a specific implementation. Use tagged when the consumer needs a collection of implementations.
- Contextual: one interface, one implementation per consumer. Removes if/switch from business logic.
- Tagged: one tag, multiple implementations. Returns a collection to the consumer.
- Tagged bindings avoid the service locator anti-pattern (calling
app()->make() in business code). - Both compose: a consumer can have a contextual binding AND receive tagged bindings for other dependencies.
- Use tagged bindings for plugin architectures, notification channels, and middleware pipelines.
User A's Cart Data Returned to User B Under Laravel Octane
singleton() to scoped(). Scoped bindings are flushed between Octane requests, ensuring a fresh instance per request.
2. Audited all singleton registrations for request-scoped state. Found 3 additional services holding Auth references that were also changed to scoped().
3. Added an Octane middleware that calls app()->forgetScopedInstances() at the start of each request as a safety net.
4. Added a test that resolves a scoped service, modifies its state, flushes scoped instances, resolves again, and asserts the state is clean.
5. Configured Octane's --max-requests flag to recycle workers every 500 requests as an additional safety layer.- singleton() persists across requests in Octane. Any service holding per-request state (auth, cart, tenant, locale) MUST use
scoped(). - php artisan serve does not reproduce Octane lifecycle bugs. Always test under Octane before deploying.
- Audit all singleton registrations when adopting Octane. grep -r 'singleton' app/Providers/ and check each for request-scoped state.
- scoped() is not a performance downgrade — it rebuilds once per request, same as singleton under php artisan serve.
- Add --max-requests to Octane workers as a defense-in-depth measure. If a scoped binding is misconfigured, worker recycling limits the blast radius.
app()->when(Bar::class)->needs('apiKey')->give(config('services.api.key')).build(). Extract a shared interface, use lazy injection via app()->lazy(), or restructure so one class receives the dependency via a setter method instead of the constructor.scoped().register() and gets 'Class not found' errors.boot(), which runs after all register() phases complete.Key takeaways
singleton() for request-aware state is a data-leak waiting to happen in long-running processes.extend() method lets you decorate any binding with caching, logging, or circuit-breaking without touching the original class or its callersboot() is for resolution. Never resolve external dependencies in register()app()->instance() in tests. swap() handles cleanup; instance() causes silent test pollution.Interview Questions on This Topic
Frequently Asked Questions
That's Laravel. Mark it forged?
5 min read · try the examples if you haven't