Laravel Octane Worker Lifecycle, State Leakage, and Memory Management
#laravel #octane #performance #php

Laravel Octane Worker Lifecycle, State Leakage, and Memory Management

3 min read Mohamed Said Mohamed Said

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:

  1. Boots the Laravel application (Application::boot()).
  2. Resolves all singleton bindings registered in service providers.
  3. 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() or flush.
  • Never register listeners inside the request path.
  • Set max_requests as 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 over singleton for stateful services.
  • Static properties bypass the container and must be reset manually via Octane listeners.
  • Memory profiling via RequestHandled events catches leaks before they reach production.
  • max_requests recycling is a circuit breaker, not a fix.

Found this useful?

Frequently Asked Questions

3 questions
Q01 What is the difference between `singleton()` and `scoped()` in Octane?
`singleton()` resolves once per worker lifetime and persists across all requests. `scoped()` resolves once per request — Octane's scope reset discards and re-resolves it at the start of each new request, making it safe for stateful per-request services.
Q02 Does Octane's `flush` array fully prevent memory leaks?
It re-resolves listed services each request, which prevents state accumulation in those bindings. However, static class properties, event listener duplication, and third-party packages that use statics are not covered — those require explicit reset logic or architectural changes.
Q03 Can I use Laravel Octane with packages that were not written with it in mind?
Often yes, but you must audit the package for mutable singletons and static state. Many popular packages are already Octane-compatible. For those that are not, wrapping their state in a `flush`-listed adapter or resetting them via an `RequestReceived` listener is the standard approach.

Continue reading

More Articles

View all