Why Octane Changes Everything About State
Traditional PHP-FPM resets the entire process between requests. Octane — whether backed by Swoole, RoadRunner, or FrankenPHP — keeps a single worker alive for thousands of requests. The application is booted once; the service container, bound singletons, and static class properties all persist.
This is the source of Octane's speed. It is also the source of its most subtle bugs.
The Worker Boot Sequence
When Octane starts, each worker:
- Boots the Laravel application (
Application::boot()). - Resolves all
singletonbindings registered in service providers. - Enters a request loop, calling
Application::resetScope()between requests.
resetScope() re-binds a small set of request-scoped services (Request, Auth, Session, etc.) but it does not re-instantiate your own singletons.
// config/octane.php
'warm' => [
...Octane::defaultServicesToWarm(),
App\Services\CurrencyConverter::class, // pre-resolved on boot
],
'flush' => [
App\Services\ReportCache::class, // re-resolved every request
],
Services in warm are resolved once. Services in flush are discarded and re-resolved on every request — use flush for anything that holds per-request state.
Common Leakage Patterns
1. Singleton Accumulating State
class NotificationAggregator
{
private array $pending = [];
public function push(Notification $n): void
{
$this->pending[] = $n; // grows forever across requests
}
}
Fix: implement OctaneAware and reset in flush, or make the class request-scoped via $this->app->scoped().
// AppServiceProvider
$this->app->scoped(NotificationAggregator::class);
scoped() behaves like singleton within a single request and is automatically re-bound by Octane's scope reset.
2. Static Property Accumulation
class QueryLogger
{
public static array $log = [];
public static function record(string $sql): void
{
self::$log[] = $sql; // never cleared
}
}
Static properties are invisible to the container. Register an Octane request listener to reset them:
// OctaneServiceProvider
Octane::tick('request-reset', function () {
QueryLogger::$log = [];
});
Or better: avoid mutable statics entirely and route state through a scoped service.
3. Event Listener Duplication
If you call Event::listen() inside a request handler or a middleware that runs on every request, listeners stack up on the same dispatcher instance:
// BAD — called on every request inside a middleware
Event::listen(OrderPlaced::class, SendConfirmation::class);
Listeners should be registered once in a service provider, never inside the request path.
Memory Profiling Under Octane
Octane exposes worker memory via its status command:
php artisan octane:status
For deeper profiling, instrument the RequestHandled event:
Event::listen(RequestHandled::class, function (RequestHandled $event) {
$mb = round(memory_get_usage(true) / 1048576, 2);
logger()->channel('octane')->info("Memory: {$mb} MB", [
'url' => $event->request->url(),
]);
});
Watch for monotonically increasing memory across requests on the same worker. A flat or oscillating line is healthy; a rising line indicates a leak.
Automatic Worker Recycling
As a safety net, configure max_requests to recycle workers after a fixed number of requests:
// config/octane.php
'swoole' => [
'options' => [
'max_request' => 500,
],
],
This is not a substitute for fixing leaks — it is a circuit breaker that bounds worst-case memory growth.
Checklist Before Deploying to Octane
- Audit every singleton for mutable instance state.
- Replace mutable statics with scoped container bindings.
- Move per-request state to
scoped()orflush. - Never register listeners inside the request path.
- Set
max_requestsas a safety net, not a primary fix. - Log memory per request in staging before going live.
Key Takeaways
- Octane's speed comes from a persistent worker; that same persistence is the root of all state bugs.
scoped()is the correct tool for per-request singletons — prefer it oversingletonfor stateful services.- Static properties bypass the container and must be reset manually via Octane listeners.
- Memory profiling via
RequestHandledevents catches leaks before they reach production. max_requestsrecycling is a circuit breaker, not a fix.