Why Octane Changes Everything About Application State
In a traditional PHP-FPM setup every request boots a fresh process. Globals, static properties, and resolved service-container bindings are destroyed when the response is sent. Octane breaks that contract: a single worker process handles thousands of requests, so anything you store in a static property or register as a true singleton persists across all of them.
This is the source of the most insidious Octane bugs — user A's authenticated model leaking into user B's request, a cached database connection holding a stale transaction, or a counter that keeps incrementing across requests.
The Worker Request Lifecycle
Octane wraps each request in a sandbox. Before dispatching it calls $app->resetScope(), which re-resolves bindings tagged as scoped. After the response is sent it fires RequestHandled and runs any registered termination callbacks.
The key distinction:
- Singleton (
app()->singleton(...)) — resolved once per worker, never reset. - Scoped (
app()->scoped(...)) — resolved once per request, flushed after it. - Bind (
app()->bind(...)) — resolved fresh on everymake()call.
If you register something as a singleton that holds per-request state (e.g. the currently authenticated user, a request-specific logger context, a DTO built from Request), you have a leak.
Detecting Leaks
The fastest way to reproduce a leak locally:
php artisan octane:start --workers=1 --max-requests=0
With a single worker and no request cap, fire two requests with different authenticated users and dump the resolved singleton between them.
// routes/web.php
Route::get('/debug-singleton', function (MyStatefulService $svc) {
return $svc->userId(); // should differ per request
});
If both requests return the same user ID, MyStatefulService is a leaking singleton.
Fixing Leaks: Scoped Bindings
The cleanest fix is to change singleton to scoped in your service provider:
// Before — leaks across requests
$this->app->singleton(CurrentTenantResolver::class, function ($app) {
return new CurrentTenantResolver($app->make(Request::class));
});
// After — Octane flushes this after every request
$this->app->scoped(CurrentTenantResolver::class, function ($app) {
return new CurrentTenantResolver($app->make(Request::class));
});
Octane calls $app->forgetScopedInstances() at the end of each request cycle, so the next make() call gets a fresh instance.
Resetting Static State with octane:flush
For third-party packages that use static properties internally, register a flush callback:
use Laravel\Octane\Facades\Octane;
Octane::tick('flush-static-cache', function () {
SomePackage::resetStaticCache();
})->seconds(0); // runs after every request via the 'RequestHandled' event
Alternatively, listen to the Octane request lifecycle events directly:
use Laravel\Octane\Events\RequestHandled;
Event::listen(RequestHandled::class, function () {
MyStaticRegistry::flush();
});
Memory Management: --max-requests Is Not Optional
Even with perfect scoping, memory grows. PHP's garbage collector does not always reclaim cyclic references inside long-lived closures. Set a sane --max-requests limit:
# octane config or supervisor
max_requests=500
This tells Octane to gracefully restart the worker after 500 requests. Combine it with Supervisor's autorestart=true so the slot is immediately refilled. Monitor worker RSS with:
watch -n2 'ps aux | grep octane'
If RSS climbs linearly and does not plateau, you have a reference cycle or a collection that is never cleared.
Practical Checklist
- Audit every
singletonbinding that touchesRequest,Auth, or tenant context — convert toscoped. - Never store request-derived state in a static property without a flush callback.
- Use
--max-requestsin production; tune it based on observed memory growth. - Run
php artisan octane:start --workers=1locally and replay the same route twice with different inputs to catch leaks early. - Add a
RequestHandledlistener in staging that dumps memory usage per worker.
Takeaways
- Octane's persistent worker model means singletons live for the worker's entire lifetime, not just one request.
- Use
scopedbindings for anything that depends on per-request context. - Register
RequestHandledlisteners orOctane::tickcallbacks to flush third-party static state. --max-requestsis a safety valve, not a substitute for correct scoping.- A single-worker local setup is the fastest way to reproduce and verify leaks before they reach production.