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

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

4 min read Mohamed Said Mohamed Said

Why Worker Mode Changes Everything

FrankenPHP's worker mode keeps your Laravel application bootstrapped in memory across thousands of requests. The PHP process never dies between requests — the container, service providers, and any resolved singletons all persist. That is the source of the throughput gain, and the source of every subtle bug you will spend a weekend debugging.

This article focuses on the practical discipline required to write application code that is safe under these conditions.


The Lifecycle You Must Internalize

Under a traditional PHP-FPM setup, every request gets a fresh process. Under FrankenPHP worker mode (or Laravel Octane with any driver), the lifecycle looks like this:

  1. Bootstrap — runs once when the worker starts.
  2. Request loop$kernel->handle() runs for each request on the same container instance.
  3. Flush — Octane fires octane:request-handled and resets registered services.

Octane's RequestHandled event triggers its own cleanup, but only for services it knows about. Anything you bind yourself is your responsibility.


Identifying Dangerous Singletons

A singleton is dangerous in worker mode when it holds request-scoped state — authenticated user, request headers, per-request configuration, or accumulated data.

// DANGEROUS: holds the resolved user across requests
app()->singleton(CurrentUser::class, function ($app) {
    return new CurrentUser(
        $app['auth']->user() // resolved at bind time, not request time
    );
});

The fix is to make the resolution lazy and request-aware:

// SAFE: resolves fresh on every call
app()->bind(CurrentUser::class, function ($app) {
    return new CurrentUser($app['auth']->user());
});

If you genuinely need a singleton for performance (e.g., a compiled rule set), separate the immutable config from the mutable state:

app()->singleton(PricingEngine::class, function ($app) {
    // Rules loaded once from cache — safe, immutable
    return new PricingEngine(PricingRules::fromCache());
});

Registering Octane Flush Callbacks

Octane exposes Octane::flush() for registering cleanup callbacks that run after every request:

use Laravel\Octane\Facades\Octane;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Octane::flush([
            CurrentUser::class,
            TenantContext::class,
        ]);

        // Or a closure for finer control
        Octane::afterRequest(function () {
            app(MetricsCollector::class)->flush();
        });
    }
}

The array form calls app()->forgetInstance() on each class. The closure form lets you do custom teardown — flushing buffers, resetting static properties, or clearing in-memory queues.


Static Properties: The Hidden Landmine

Octane's flush mechanism cannot reset PHP static properties. If you have:

class QueryLogger
{
    private static array $log = [];

    public static function record(string $sql): void
    {
        self::$log[] = $sql;
    }
}

$log grows unbounded across every request in the worker. The fix is to register a reset in your afterRequest hook:

Octane::afterRequest(function () {
    QueryLogger::reset(); // clears the static array
});

Alternatively, move the state into a container-bound service that Octane can flush.


Safe Bootstrapping Pattern

For services that are expensive to construct but safe to share (database connection pools, compiled Twig environments, ML model handles), use a two-phase pattern:

class InferenceServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Singleton: model weights loaded once
        $this->app->singleton(EmbeddingModel::class, fn() =>
            EmbeddingModel::loadFromDisk(config('ai.model_path'))
        );
    }

    public function boot(): void
    {
        // Per-request context wraps the singleton safely
        $this->app->bind(EmbeddingContext::class, fn($app) =>
            new EmbeddingContext($app->make(EmbeddingModel::class))
        );
    }
}

The heavy model is a singleton. The context — which may hold per-request token counts or user preferences — is bound (not singleton), so it is reconstructed fresh each request.


Key Takeaways

  • bind vs singleton is now a correctness decision, not just a performance one.
  • Use Octane::flush([...]) and Octane::afterRequest(...) to register explicit teardown for every stateful service.
  • Static class properties bypass Octane's flush — reset them manually or eliminate them.
  • Separate immutable configuration (safe to share) from mutable request context (must be rebound).
  • Test worker-mode safety by running your test suite with OCTANE_TESTING=true and asserting no state leaks between simulated requests.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does FrankenPHP worker mode require Laravel Octane?
Yes. FrankenPHP's worker mode integrates with Laravel through the Octane package, which manages the request loop, lifecycle events, and flush callbacks. Without Octane, you would need to implement the worker loop and teardown yourself.
Q02 How do I test for state leaks between requests in worker mode?
Octane ships with a `withOctane()` test helper and a `OctaneTestCase` base. You can simulate multiple sequential requests in a single test and assert that state from request one does not bleed into request two. Also audit any `static` properties and container singletons that hold mutable data.
Q03 Is it safe to use Laravel's Auth facade in a singleton resolved at boot time?
No. The Auth facade resolves the guard lazily, but if you resolve `auth()->user()` inside a singleton's constructor at boot, you capture a null or stale user for the lifetime of the worker. Always resolve the authenticated user inside a `bind` (per-request) service or directly within the controller/action.

Continue reading

More Articles

View all