Picture the bug report that lands in staging on a Friday afternoon: a logged-out smoke test gets greeted with another tester's account data. Nobody touched the auth code. What changed is the runtime, because the team just switched a Symfony app to FrankenPHP's worker mode. Here is my position, stated up front: that bug is not an argument against FrankenPHP. It is the single best argument for at least trying it. For two decades, PHP's per-request teardown has been quietly absorbing our sloppiest habits, and I think losing that free pass is one of the healthiest things that can happen to a mature codebase.

worker.php
<?php
// Deliberately broken: static state outlives the request.
final class RequestContext
{
    public static ?int $userId = null;
}

$handler = static function (): void {
    if (isset($_GET['user_id'])) {
        RequestContext::$userId = (int) $_GET['user_id'];
    }
    echo 'Current user: ' . (RequestContext::$userId ?? 'guest');
};

while (frankenphp_handle_request($handler)) {
}

Quick grounding for anyone who has only seen the name fly by. FrankenPHP embeds the official PHP interpreter directly into Caddy via CGO and runs your code on a pool of POSIX threads. No PHP-FPM daemon, no FastCGI socket, one process serving TLS, HTTP/2 and HTTP/3, static files and PHP in a single log stream. In classic mode it behaves like the setup you already know: every request starts fresh, and you can drop it under an existing app without rewriting anything. Worker mode is the interesting part. Your application boots once, then stays resident and answers request after request from the same process.

Now think about what the traditional model has been doing for you all along. Every static property, every memoized singleton, every config value you stuffed somewhere global got shredded the moment the response went out. That teardown was a nightly janitor cleaning up after code that never learned to clean up after itself. Worker mode fires the janitor. Statics survive. Event listeners you register inside a request handler stack up, one more per request. A tenant ID cached in a helper class outlives the tenant. Even the superglobals need reading the fine print: FrankenPHP resets most of them between requests, but the docs currently note that $_ENV is not reset, so environment data has to be treated as strictly immutable and never used to smuggle per-request values around.

The source article I am riffing on shows the failure in eleven lines, and it is worth staring at. One request sets a user ID on a static property. The next request sends nothing and still sees user 42, because the class stayed in memory. None of that is a defect in FrankenPHP. Keeping the process alive is exactly where the speedup comes from, since the Composer autoload, container build and kernel construction happen once instead of on every hit. Laravel's Octane integration ships a --max-requests option and recycles workers after a bounded number of requests by default, which caps memory creep. But recycling is a smoke detector. It tells you something is burning; it does not put the fire out in your code.

So why do I call this a lie detector rather than a hazard? Because every defect it surfaces was already a defect wearing a disguise. If your app misbehaves when the process lives longer than one request, you almost certainly have the same rot showing up elsewhere: flaky test suites that pass in isolation and fail in sequence, queue workers that need a nightly restart, memory graphs that only ever slope upward. You have been running long-lived PHP for years in your consumers and daemons. Worker mode just applies that discipline to the web tier, and the frameworks meet you halfway: Octane installs the FrankenPHP server with two artisan commands, and Symfony supports the worker model natively from 7.4, with a PHP Runtime package covering older versions.

The honest counterargument deserves its full weight. FrankenPHP requires thread-safe PHP, and the compatibility list has holes: imap, newrelic and pcov are currently unsupported, and imagick comes with documented caveats. If New Relic is load-bearing in your observability story, that alone can end the conversation today. The performance docs also steer demanding workloads toward Debian images, because threaded PHP on musl, which Alpine uses, runs measurably slower. And if your team operates a hardened Nginx plus PHP-FPM platform with years of dashboards, runbooks and on-call reflexes behind it, swapping that out to make the architecture diagram prettier is a bad trade. A boring CRUD monolith at modest traffic will not feel any of this.

I still land where I started, for a reason that has little to do with throughput. Run FrankenPHP in classic mode and you get the operational consolidation on its own: one binary, automatic certificates, Prometheus metrics for busy threads, request time and queue depth, no more grepping two services to reconstruct one failed request. Then point a staging environment at worker mode and hammer it with interleaved requests for different users, tenants and locales. Either it holds, and you have latency headroom waiting to be claimed, or it leaks, and you now own a precise map of every place your application confuses global state with request state. Both outcomes are worth more than the afternoon they cost.

Here is what I want to know from you. Have you actually flipped worker mode on against a codebase older than five years, and what crawled out? A static logger holding a request context, a listener registered ten thousand times, something weirder? And for those who tried it and went back to PHP-FPM: was it the extension gaps, the debugging story, or did the isolation bugs simply cost more than the bootstrap savings paid? The comments are open, and I suspect the war stories are better than any benchmark.