Octane Worker Lifecycle, State Leakage, and Memory Management in Production
#laravel #octane #performance #swoole #roadrunner

Octane Worker Lifecycle, State Leakage, and Memory Management in Production

4 min read Mohamed Said Mohamed Said

Why Octane Changes Everything About Application State

Traditional PHP-FPM boots the framework on every request and discards all memory when the response is sent. Octane inverts this: a single worker process boots Laravel once and then handles thousands of requests in that same process. The performance gain is real — but the contract your code must honour changes completely.

Understanding the worker lifecycle is the prerequisite for running Octane safely.

The Worker Lifecycle in Detail

With Swoole or RoadRunner, Octane's boot sequence looks like this:

  1. Worker bootApplication::boot() runs, all service providers fire, singletons are resolved and cached in the container.
  2. Request loop — for each incoming request Octane clones a "sandbox" application, binds fresh Request, Auth, Session, and a handful of other stateful services, then dispatches through the kernel.
  3. Flush — after the response is sent, Octane fires RequestHandled and resets the services registered in octane.php under flush.

The critical insight: anything not in the flush list persists across requests.

Common Leakage Patterns

1. Singletons That Accumulate State

// AppServiceProvider
$this->app->singleton(ReportBuilder::class, function () {
    return new ReportBuilder();
});

// ReportBuilder.php
class ReportBuilder
{
    private array $filters = [];

    public function addFilter(string $f): static
    {
        $this->filters[] = $f; // leaks across requests!
        return $this;
    }
}

Request 1 adds ['status' => 'active']. Request 2 resolves the same instance and sees that filter already present. Fix: make the class immutable, or register it as scoped instead of singleton.

$this->app->scoped(ReportBuilder::class, fn () => new ReportBuilder());

scoped bindings are flushed by Octane automatically at the end of each request — they behave like singletons within a request but are discarded afterward.

2. Static Properties

class TenantContext
{
    public static ?Tenant $current = null;
}

Octane's sandbox clone does not reset static class properties. If middleware sets TenantContext::$current and a subsequent request hits the same worker before middleware runs, it reads the previous tenant. Use app()->scoped() or reset explicitly in an Octane RequestHandled listener.

// OctaneServiceProvider or EventServiceProvider
Octane::tick('reset-tenant', function () {
    TenantContext::$current = null;
});

3. Closures Captured in Long-Lived Objects

$this->app->singleton(EventDispatcher::class, function ($app) {
    $d = new EventDispatcher();
    $d->listen('*', function ($event) use ($app) {
        // $app is the root container — fine.
        // but if you capture $request here, it leaks.
    });
    return $d;
});

Capturing the current Request or Auth object inside a singleton closure is a classic leak. Always resolve them lazily from the container at call time.

Memory Management

Octane workers will grow in memory over time even with correct code, because PHP's garbage collector does not always collect cyclic references immediately.

Configure max_requests in octane.php:

'swoole' => [
    'options' => [
        'max_request' => 500,
    ],
],

After 500 requests the worker restarts cleanly. Tune this number based on your memory growth rate — profile with memory_get_usage() logged in a RequestHandled listener.

Use octane:status and Horizon-style metrics to watch worker memory in production. A worker that grows beyond ~128 MB before its max_requests limit is a signal of a genuine leak.

Verifying Your Code Is Octane-Safe

The laravel/octane package ships a --watch flag for development, but for correctness testing:

php artisan octane:start --workers=1 --max-requests=1000

Then hammer a route that exercises your singletons with wrk or ab and assert response consistency. Automated: write a Pest test that dispatches the same request twice through the kernel and asserts no state bleeds.

it('does not leak filters between requests', function () {
    $kernel = app(\Illuminate\Contracts\Http\Kernel::class);

    $r1 = $kernel->handle(Request::create('/report?status=active'));
    $r2 = $kernel->handle(Request::create('/report'));

    expect($r2->getContent())->not->toContain('active');
});

Key Takeaways

  • Use scoped instead of singleton for any service that holds per-request state.
  • Never store the current Request, Auth, or Session in a singleton property or static variable.
  • Reset static class properties in an Octane RequestHandled listener or tick.
  • Set max_requests to recycle workers and cap worst-case memory growth.
  • Profile memory growth per request in staging before deploying Octane to production.

Found this useful?

Frequently Asked Questions

3 questions
Q01 What is the difference between `singleton` and `scoped` in an Octane context?
A `singleton` is resolved once per worker process and persists for the entire lifetime of that worker. A `scoped` binding is resolved once per request and automatically flushed by Octane at the end of each request, making it safe for stateful per-request services.
Q02 Does Octane automatically reset all Laravel services between requests?
Only the services listed under the `flush` key in `config/octane.php` are reset automatically. Core stateful services like `Request`, `Auth`, and `Session` are included by default, but any custom singletons you register are your responsibility.
Q03 How do I detect a state leak in an Octane application?
Run a single worker with a high `max_requests` value and send the same request repeatedly with different inputs. Log the response or assert consistency in a Pest test that calls the kernel twice in the same process. Diverging responses between calls indicate leaked state.

Continue reading

More Articles

View all