Laravel Octane + FrankenPHP: Shared State, Request Isolation, and Safe Singleton Patterns
#laravel #octane #frankenphp #performance #architecture

Laravel Octane + FrankenPHP: Shared State, Request Isolation, and Safe Singleton Patterns

4 min read Mohamed Said Mohamed Said

The Problem With Long-Lived Workers

Traditional PHP-FPM boots the framework on every request and discards everything afterward. Octane — whether backed by Swoole, RoadRunner, or FrankenPHP — keeps a single worker process alive across thousands of requests. That is the source of its speed advantage, and the source of its most subtle bugs.

When you bind a class as a singleton in a service provider, that instance persists for the lifetime of the worker. Any mutable state it holds bleeds from one request into the next.

// AppServiceProvider.php — dangerous under Octane
$this->app->singleton(CartService::class, function () {
    return new CartService(); // holds $this->items = []
});

Request A adds items to the cart. Request B — from a completely different user — resolves the same CartService instance and sees Request A's items. This is a classic Octane state-leak.

Scoped Bindings: The Right Tool

Laravel ships a first-class solution: scoped(). A scoped binding behaves like a singleton within a single request lifecycle, then is flushed automatically by Octane between requests.

// Correct: scoped to one request
$this->app->scoped(CartService::class, function () {
    return new CartService();
});

Octane calls $app->forgetScopedInstances() at the end of every request, so the next request gets a fresh instance. No manual cleanup required.

When scoped() Is Not Enough

Some objects are legitimately shared across requests — database connection pools, compiled route collections, config repositories. The rule of thumb:

  • Stateless or immutablesingleton() is fine.
  • Mutable, request-specific → use scoped().
  • Mutable, shared intentionally → use singleton() but make mutation thread-safe (Swoole) or accept that FrankenPHP workers are isolated processes.

FrankenPHP's worker mode uses separate PHP fibers per request within the same process, so true shared memory between concurrent requests is not a concern the way it is with Swoole coroutines. However, sequential requests in the same worker still share the same singleton instances.

Flush Hooks for Third-Party Singletons

You do not always control whether a package registers a singleton or a scoped binding. Octane provides flush hooks to reset specific instances between requests.

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

For more granular control, listen to the RequestReceived and RequestTerminated Octane events:

use Laravel\Octane\Events\RequestReceived;
use Laravel\Octane\Events\RequestTerminated;

Event::listen(RequestReceived::class, function ($event) {
    $event->sandbox->forgetInstance(ReportBuilder::class);
});

$event->sandbox is the per-request application clone Octane creates — mutating it does not affect the master container.

Detecting Leaks Before Production

Octane ships with a --watch flag for development, but it will not surface state leaks automatically. A practical detection pattern is to inject a unique request ID into any suspicious singleton and assert it changes between requests in a test:

it('creates a fresh CartService per request', function () {
    $first = $this->get('/cart')->json('session_id');
    $second = $this->get('/cart')->json('session_id');

    expect($first)->not->toBe($second);
});

For deeper inspection, Telescope's request watcher combined with a custom Octane:RequestTerminated listener that dumps resolved instances is invaluable during staging.

Safe Patterns at a Glance

// 1. Immutable value object — singleton is fine
$this->app->singleton(CurrencyFormatter::class);

// 2. Request-scoped accumulator — use scoped
$this->app->scoped(AuditLog::class);

// 3. External client with connection pool — singleton, but reset on error
$this->app->singleton(RedisClient::class, fn() => new RedisClient(config('redis')));

Takeaways

  • Use scoped() for any service that accumulates request-specific state; Octane flushes it automatically.
  • singleton() is safe only for stateless or intentionally shared objects.
  • FrankenPHP workers are isolated processes, not coroutines — concurrent request bleed is sequential, not parallel.
  • Octane's flush config key and RequestReceived event are your escape hatches for third-party singletons.
  • Write a simple request-ID test to catch 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() persists for the entire worker lifetime across all requests. A scoped() binding behaves like a singleton within a single request and is automatically flushed by Octane between requests, preventing state from leaking to the next user.
Q02 Does FrankenPHP have the same concurrency risks as Swoole coroutines?
No. FrankenPHP uses separate PHP fibers per request within a worker process, so concurrent requests do not share memory the way Swoole coroutines can. However, sequential requests in the same worker still share singleton instances, so scoped bindings are still essential.
Q03 How do I reset a third-party package's singleton between Octane requests?
Add the class to the flush array in config/octane.php, or listen to the Octane RequestReceived event and call $event->sandbox->forgetInstance(YourClass::class) to drop the instance before the next request resolves it.

Continue reading

More Articles

View all