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:
- Bootstrap — runs once when the worker starts.
- Request loop —
$kernel->handle()runs for each request on the same container instance. - Flush — Octane fires
octane:request-handledand 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
bindvssingletonis now a correctness decision, not just a performance one.- Use
Octane::flush([...])andOctane::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=trueand asserting no state leaks between simulated requests.