Octane Worker Lifecycle, State Leakage, and Safe Singleton Patterns in Laravel
#laravel #octane #performance #architecture

Octane Worker Lifecycle, State Leakage, and Safe Singleton Patterns in Laravel

4 min read Mohamed Said Mohamed Said

Why Octane Changes Everything About Application State

Traditional PHP-FPM boots a fresh process per request. Laravel Octane — whether backed by Swoole, RoadRunner, or FrankenPHP — keeps a single worker alive and serves thousands of requests through it. Boot time drops dramatically, but the trade-off is brutal: anything bound as a singleton in the service container persists across requests unless you explicitly flush it.

Understanding the worker lifecycle is the first step to writing Octane-safe code.

The Worker Lifecycle in Three Phases

Boot phase      → app()->boot(), all providers registered, singletons created
Request phase   → clone of the app (sandbox) handles the request
Reset phase     → sandbox discarded, base app state partially restored

Octane creates a sandbox — a shallow clone of the application container — for each request. Bindings marked as singletons in the base app are not re-instantiated; the same object is handed to every sandbox. That is the leakage vector.

Where State Leakage Actually Happens

1. Mutable Singleton Properties

class CartService
{
    private array $items = [];

    public function add(int $productId): void
    {
        $this->items[] = $productId;
    }

    public function all(): array
    {
        return $this->items;
    }
}

Bound as a singleton, $items accumulates across every request in the same worker. User A's cart bleeds into User B's.

2. Static Properties

Static properties are not scoped to the container at all — they live on the class itself and survive every request, every sandbox, every flush.

class FeatureFlags
{
    private static array $resolved = [];
    // Never safe to cache per-request data here under Octane
}

3. Closures Capturing Request-Scoped Objects

A service provider that captures $request or Auth::user() inside a closure at boot time will freeze that value for the worker's lifetime.

The Octane Reset Hook

Octane fires Illuminate\Foundation\Events\RequestHandled and exposes a dedicated octane:request-handled hook. The cleaner API is the OctaneServiceProvider:

use Laravel\Octane\Facades\Octane;

class CartServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Octane::tick('flush-cart', function () {
            // runs between requests — avoid heavy work here
        });

        $this->callAfterResolving(CartService::class, function (CartService $service) {
            // not useful for reset; use the listener below
        });
    }
}

The correct approach is registering a listener on RequestHandled or using Octane's flush list:

// config/octane.php
'flush' => [
    \App\Services\CartService::class,
],

Octane calls flush() on each listed class after every request. Implement the interface:

use Laravel\Octane\Contracts\ServesStaticFiles; // wrong example
// Octane checks for a flush() method via duck-typing

class CartService
{
    private array $items = [];

    public function flush(): void
    {
        $this->items = [];
    }
}

Writing Genuinely Safe Singletons

The safest pattern is immutable singletons — services that hold only configuration or collaborators, never per-request data.

class PricingEngine
{
    public function __construct(
        private readonly TaxRateRepository $taxes,
        private readonly CurrencyConverter $converter,
    ) {}

    public function calculate(Money $amount, string $currency): Money
    {
        // Pure computation; no state stored on $this
        return $this->converter->convert(
            $amount->add($this->taxes->rateFor($currency)),
            $currency,
        );
    }
}

When you genuinely need per-request state, bind it scoped rather than as a singleton:

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

scoped() behaves like a singleton within a single request sandbox but is re-instantiated for the next request — exactly the semantics you want under Octane.

Detecting Leakage Before Production

Run your test suite with Octane's --workers=1 flag and fire the same endpoint twice in the same worker process. A simple integration test:

it('does not leak cart state between requests', function () {
    $this->post('/cart', ['product_id' => 1]);
    $this->post('/cart', ['product_id' => 2]);

    $response = $this->getJson('/cart');

    // Should contain only items from the current request session
    $response->assertJsonCount(1, 'data');
});

Pair this with Octane's built-in octane:status command and memory profiling to catch workers that grow unboundedly.

Key Takeaways

  • Octane sandboxes clone the container but share singleton instances from the base app.
  • Mutable singleton properties and static class properties are the two most common leakage sources.
  • Use scoped() for per-request services; reserve singleton() for truly stateless collaborators.
  • Register a flush() method and add the class to config/octane.php flush for services that must reset.
  • Write immutable services by default — pure computation over stored state eliminates the problem entirely.

Found this useful?

Frequently Asked Questions

3 questions
Q01 What is the difference between singleton() and scoped() in Laravel Octane?
singleton() creates one instance for the entire worker lifetime, shared across all requests. scoped() creates one instance per request sandbox and is discarded after each request completes, making it safe for per-request state under Octane.
Q02 Does the Octane flush list work with RoadRunner as well as Swoole?
Yes. The flush mechanism is implemented at the Octane layer, not the server driver layer. Any class listed in config/octane.php under 'flush' will have its flush() method called between requests regardless of whether you use Swoole, RoadRunner, or FrankenPHP.
Q03 Are static properties automatically reset between Octane requests?
No. Static properties live on the class itself and are completely outside the container's scope. Octane has no mechanism to reset them automatically. You must reset static state manually in a RequestHandled listener or avoid static mutable state entirely.

Continue reading

More Articles

View all