Twenty consecutive requests, twenty fresh MySQL connections. That is PHP-FPM doing exactly what it was designed to do: hand you a clean process, then burn everything down when the response goes out. The same twenty requests against FrankenPHP in worker mode opened zero new connections, because the one established at startup served them all. The measurement comes from a French-language write-up about migrating a Sulu site, and it is the most convincing FrankenPHP piece I have read this year, precisely because its headline number is small.
Small, as in 30 to 40 milliseconds shaved off a response that now sits around 155 ms. Not the factor ten you see on conference slides. And that modesty is exactly why you should trust it. Worker mode removes one thing: the setup work your framework repeats on every request. It does nothing for your SQL, nothing for template rendering, nothing for your domain logic. If a page spends 400 ms in the database, worker mode will politely watch it spend 400 ms in the database. Anyone promising more is selling you their benchmark harness.
The obvious objection is that we already cache all this. We do, and it does not cover the expensive part. OPcache keeps compiled bytecode in shared memory, so PHP stops reparsing your two thousand files. Symfony's compiled container in var/cache/prod/ means the framework stops re-reading YAML. But under FPM, the objects built from all that cached code, the kernel instance, the wired container, Doctrine's entity metadata, the open database connection, are torn down at the end of every request and rebuilt at the start of the next one. Several megabytes of prepared state, assembled and discarded, hundreds of times a minute. Worker mode keeps those objects alive across hundreds of requests. That is the whole trick, and it is enough.
Now the honest part, because a process that remembers things also remembers your mistakes. The report's back office broke in a way I find instructive: Sulu decides at kernel boot whether it is serving the public site or the admin, a worker freezes that decision forever, and the stock index.php contains an exit that kills a worker loop dead. Result: /admin returned 404 in production. The fix was routing the admin through a classic front controller and keeping only the public site in the worker. Memory is the other worry, and here the numbers are reassuring rather than triumphant: 166 MB shortly after a restart, 271 MB after roughly 18 hours of traffic, plateauing well under a 1 GB cap. Three guard rails make that plateau happen: the Symfony runner calls gc_collect_cycles() after every request, FrankenPHP recycles each worker after 500 requests by default (tunable via FRANKENPHP_LOOP_MAX), and the container memory limit catches whatever escapes. Nobody is claiming your leaks vanish. The system just refuses to let them compound forever, which is the grown-up version of the promise.
The operational catch is the one I would tattoo on every deploy script: frankenphp reload does not replace your workers. It reloads Caddy's configuration and leaves three processes holding a kernel that points at a service container your cache:clear just deleted. The site answers 500 until someone runs a real restart, docker compose restart php in this setup. Pair that with opcache.validate_timestamps set to 0, which stops PHP from stat()ing thousands of files per request but also means hand-edits on the server silently do nothing. Both settings are safe only because deployment restarts PHP automatically. I hear the complaint that this is fragile. I would flip it: if your process still depends on editing files on a live box and hoping, worker mode did not create that risk, it just stopped covering for it.
There is a quieter win underneath the worker story. FrankenPHP is Kévin Dunglas embedding the PHP engine inside Caddy, the Go server Matthew Holt started in 2014, so one binary now terminates TLS, speaks HTTP/1.1 through HTTP/3, and serves static files. The team behind the report dropped nginx entirely, and it turned out nginx's rate limiting had caused the slowdown that triggered the whole investigation. One config to read instead of two places to be wrong. My favorite detail: precompressed brotli assets built once at deploy time at maximum quality. Their stylesheet went from 88 KB raw to 24 KB compressed on the fly to 14 KB precompressed. And yes, there is something satisfying about PHP borrowing Go's concurrency machinery wholesale, goroutines and all, without anyone having to rewrite a controller.
What impresses me most is what they measured and then declined. The JIT: near zero gain, because a web app lives in I/O and string and array work, not numeric loops, so it stays off. opcache.preload: redundant when three resident workers already keep the kernel hot, and risky with Sulu's annotation reading. A second HTTP cache in Caddy: rejected, because Symfony's cache already knows how to invalidate itself and a second cache means a second invalidation path to keep honest. That is engineering as subtraction, and it is rarer than it should be.
So the ledger reads: roughly 190 ms down to 155 ms, about 140 MB of RAM held permanently by three workers, and a deploy pipeline that must restart PHP without a human in the loop. On this evidence I would take that trade on most Symfony or Laravel apps with memory to spare, and skip it on anything leaning on accumulating statics or scattered exit calls. But I know my threshold is not yours. Would you park 140 MB of always-on memory to save 35 ms per request, and if you have already flipped the switch, what was the first thing that broke when your application suddenly remembered the previous request?




Comments
No comments yet — be the first.
Open the discussion
No account or password needed — just enter your e-mail and we’ll send you a one-time sign-in link. First time here? You’re set up automatically.
Your rating will be applied automatically after you sign in.
Check your inbox
We’ve sent a sign-in link to …. Open it on this device — this tab will sign you in automatically.
Nothing arrived? Check your spam folder — and mark the mail as "Not spam" so it lands in your inbox next time.