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

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 every make() 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 singleton binding that touches Request, Auth, or tenant context — convert to scoped.
  • Never store request-derived state in a static property without a flush callback.
  • Use --max-requests in production; tune it based on observed memory growth.
  • Run php artisan octane:start --workers=1 locally and replay the same route twice with different inputs to catch leaks early.
  • Add a RequestHandled listener 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 scoped bindings for anything that depends on per-request context.
  • Register RequestHandled listeners or Octane::tick callbacks to flush third-party static state.
  • --max-requests is 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 What is the difference between `singleton` and `scoped` in Laravel Octane?
A `singleton` is resolved once per worker process and persists across all requests that worker handles. A `scoped` binding is also resolved once, but Octane flushes it after each request via `forgetScopedInstances()`, so the next request gets a fresh instance.
Q02 How do I safely use a package that stores state in static properties under Octane?
Listen to the `Laravel\Octane\Events\RequestHandled` event and call the package's reset or flush method inside the listener. If the package has no reset method, consider wrapping it in a scoped service that re-instantiates it per request.
Q03 Should I set `--max-requests` even if I have no obvious memory leaks?
Yes. PHP's garbage collector does not always reclaim cyclic references in long-lived closures. A bounded `--max-requests` value (e.g. 500–1000) provides a safety net and keeps worker RSS predictable in production.

Continue reading

More Articles

View all