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

Laravel Octane + FrankenPHP: Persistent Services, Boot-Once Singletons, and Safe State

4 min read Mohamed Said Mohamed Said

The Problem Nobody Warns You About

When you move a Laravel application from PHP-FPM to Octane (whether backed by Swoole, RoadRunner, or FrankenPHP), the process no longer dies after each request. The service container is booted once and reused. That is the entire source of the performance gain — and the entire source of subtle bugs.

Understanding which bindings survive across requests, which must be re-resolved per request, and how to enforce that boundary is the difference between a stable production deployment and a system that leaks user data between sessions.

How the Container Lifetime Works

Octane boots the application once in the worker process, then for each incoming request it:

  1. Creates a sandbox clone of the container.
  2. Re-binds a small set of core request-aware services (Request, Auth, Session, Cookie, etc.).
  3. Runs your route/middleware stack against the sandbox.
  4. Discards the sandbox after the response is sent.

The base container — and every singleton registered in a service provider's register() method — persists for the lifetime of the worker.

// This singleton is booted ONCE and shared across every request.
// Safe only if it holds no per-request state.
$this->app->singleton(ReportRenderer::class, function () {
    return new ReportRenderer(config('reports'));
});

If ReportRenderer caches a reference to the current Auth::user() internally, you have a data-leak bug.

Identifying Dangerous Singletons

The rule is simple: a singleton is safe if its constructor and methods are pure with respect to request identity. Danger signs:

  • Storing $this->user = auth()->user() in a property.
  • Injecting Request via the constructor (not a closure).
  • Caching database results in an instance property without a TTL.
  • Holding an open database transaction reference.

Octane ships with a octane:check command (Laravel 11+) that statically warns about some of these, but it cannot catch everything.

The Correct Pattern: Request-Scoped Bindings

For services that must know about the current request, register them as scoped bindings. Octane flushes scoped bindings between requests automatically.

// ServiceProvider::register()
$this->app->scoped(CurrentTenant::class, function (Application $app) {
    return new CurrentTenant(
        $app->make(Request::class)->header('X-Tenant-ID')
    );
});

scoped() behaves like singleton() within a single request lifecycle but is re-resolved on the next request. This is the correct tool for anything tenant-aware, user-aware, or request-context-aware.

Boot-Once Singletons: Maximising the Warm-Up Benefit

For genuinely stateless, expensive-to-construct services — compiled Twig environments, parsed config trees, HTTP client pools — you want them to survive across requests. Make that intent explicit:

$this->app->singleton(CompiledRuleEngine::class, function () {
    // Expensive: parses 400 YAML rule files.
    return RuleEngineFactory::fromDisk(storage_path('rules'));
});

Document this in a @octane-persistent docblock annotation (a convention, not a framework feature) so future maintainers know the class must remain stateless.

Detecting Leaks in Tests

With Pest, you can assert that a scoped binding is not accidentally promoted to a singleton:

it('resolves a fresh CurrentTenant per request sandbox', function () {
    $app = app();

    $first = $app->make(CurrentTenant::class);
    // Simulate Octane flushing scoped instances.
    $app->forgetScopedInstances();
    $second = $app->make(CurrentTenant::class);

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

forgetScopedInstances() is the same method Octane calls internally between requests, making this a faithful unit test.

FrankenPHP-Specific Considerations

FrankenPHP's worker mode runs the Laravel bootstrap inside a loop. Unlike Swoole, it does not use coroutines, so there is no concurrent request sharing within a single worker. However, the same singleton lifetime rules apply. One additional concern: static class properties are never reset by Octane's sandbox mechanism. If a third-party package accumulates state in a static array, you must reset it manually via Octane's RequestHandled event:

use Laravel\Octane\Events\RequestHandled;

Event::listen(RequestHandled::class, function () {
    SomePackage::resetStaticCache();
});

Takeaways

  • Use singleton() only for stateless, request-identity-free services.
  • Use scoped() for anything that touches auth, tenancy, or the current request.
  • forgetScopedInstances() in Pest tests faithfully simulates Octane's request boundary.
  • Static class properties bypass Octane's sandbox — reset them via RequestHandled.
  • Run php artisan octane:check as a CI step, but treat it as a starting point, not a guarantee.

Found this useful?

Frequently Asked Questions

3 questions
Q01 What is the difference between singleton() and scoped() in Laravel Octane?
singleton() resolves once and caches the instance for the entire worker lifetime — across all requests. scoped() also caches within a single request, but Octane flushes it between requests. Use scoped() for anything that depends on the current user, tenant, or HTTP request context.
Q02 Does FrankenPHP worker mode behave differently from Swoole regarding state leakage?
The container lifetime rules are identical. FrankenPHP does not use coroutines, so there is no intra-worker concurrency concern, but singletons still persist across requests. Static class properties are also unaffected by Octane's sandbox in both runtimes and must be reset manually.
Q03 How can I detect accidental singleton promotion in a Pest test suite?
Call app()->forgetScopedInstances() between two make() calls in your test. If both calls return the same object instance, the binding was registered as a singleton instead of scoped. This mirrors exactly what Octane does between real HTTP requests.

Continue reading

More Articles

View all