Laravel Octane + FrankenPHP: Persistent Services, Shared State, and Safe Singletons
#laravel #octane #frankenphp #performance #architecture

Laravel Octane + FrankenPHP: Persistent Services, Shared State, and Safe Singletons

4 min read Mohamed Said Mohamed Said

The Problem Nobody Talks About Until Production

When you move a Laravel application from PHP-FPM to Octane (whether backed by Swoole, RoadRunner, or FrankenPHP), the container is bootstrapped once and then reused across every request handled by that worker. That single fact invalidates a large class of assumptions most Laravel code silently makes.

This article focuses on the practical patterns you need to write services that behave correctly under persistent workers — and how to audit existing code before you flip the switch.


Why Singletons Become Dangerous

Under PHP-FPM every request gets a fresh process. A singleton bound in a service provider is instantiated once per request, so stale state is impossible — the process dies.

Under Octane a worker process handles thousands of requests. A singleton is instantiated once for the lifetime of the worker. Any mutable state it accumulates is visible to every subsequent request.

// Dangerous under Octane
class CartService
{
    private array $items = [];

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

Request 1 adds item 42. Request 2 from a completely different user now sees item 42 in $this->items.


The Octane Flush Hook

Octane ships with a RequestHandled event and a dedicated OctaneServiceProvider hook for resetting state between requests.

use Laravel\Octane\Facades\Octane;

class CartServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Octane::tick('flush-cart', function () {
            $this->app->forgetInstance(CartService::class);
        })->everySeconds(0); // runs between every request
    }
}

A cleaner approach is to listen to the RequestHandled event directly:

use Laravel\Octane\Events\RequestHandled;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->app['events']->listen(RequestHandled::class, function () {
            $this->app->forgetInstance(CartService::class);
        });
    }
}

Now CartService is re-instantiated fresh on the next request, eliminating the leak.


Designing Octane-Safe Services From the Start

Prefer Immutable Value Objects

If a service only holds configuration (injected at construction) and never accumulates mutable state, it is safe as a singleton.

final class CurrencyFormatter
{
    public function __construct(
        private readonly string $locale,
        private readonly string $currency,
    ) {}

    public function format(int $cents): string
    {
        return \NumberFormatter::create($this->locale, \NumberFormatter::CURRENCY)
            ->formatCurrency($cents / 100, $this->currency);
    }
}

No mutable fields — safe to keep alive for the worker's entire lifetime.

Inject Request-Scoped Data via Method Arguments

Avoid storing per-request data on the service. Pass it as method arguments instead.

// Bad: stores request context on the singleton
class AuditLogger
{
    private ?User $actor = null;

    public function setActor(User $user): void { $this->actor = $user; }
    public function log(string $event): void { /* uses $this->actor */ }
}

// Good: actor is a method parameter
class AuditLogger
{
    public function log(User $actor, string $event): void { /* stateless */ }
}

Use scoped() for Request-Lifetime Bindings

Laravel's scoped() binding (introduced for Octane compatibility) registers a singleton that Octane automatically flushes between requests:

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

This is the idiomatic solution — prefer it over manual forgetInstance calls.


Auditing Existing Code

Before enabling Octane on an existing app, grep for these patterns:

  • $this->app->singleton(...) — check if the resolved class holds mutable instance state.
  • Static properties on any class (private static array $cache = []) — these persist across requests and across workers sharing the same process.
  • Facades that resolve to singletons with state (e.g., a custom Auth driver that caches the resolved user).
grep -rn 'static \$' app/
grep -rn 'singleton(' app/Providers/

For each hit, ask: does this class accumulate state that must be per-request? If yes, switch to scoped() or add a flush listener.


Takeaways

  • Under Octane/FrankenPHP, singleton() bindings live for the worker's lifetime — mutable state leaks across requests.
  • Use scoped() for any service that holds per-request state; Octane flushes scoped bindings automatically.
  • Immutable services (pure config, no mutable fields) are safe as true singletons and benefit from zero re-instantiation cost.
  • Audit static properties carefully — they survive even forgetInstance calls.
  • Listen to RequestHandled for custom teardown logic that scoped() cannot cover.

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 process lifetime. `scoped()` also creates one instance per worker, but Octane automatically flushes and re-instantiates it between requests, making it safe for services that hold per-request state.
Q02 Do static class properties get reset between Octane requests?
No. Static properties persist for the entire worker process lifetime and are not affected by `forgetInstance` or `scoped()` bindings. You must reset them manually in a `RequestHandled` listener or redesign the code to avoid static mutable state.
Q03 Is FrankenPHP's worker mode identical to Swoole for these purposes?
The container lifecycle is the same — one bootstrap per worker, persistent across requests. The flush hooks and `scoped()` binding behavior work identically. The main differences are in the underlying HTTP server implementation, not in how Laravel manages service lifetimes.

Continue reading

More Articles

View all