FrankenPHP 1.9.0 was tagged yesterday, which is as good a moment as any to write up the six weeks I spent trying to get rid of PHP-FPM on one application. Short version: p95 went from 214 ms to 79 ms and sustained throughput went from 148 to 402 requests per second on the same box. Two of those six weeks went to a memory leak that was entirely our own doing.
The application is a Symfony 6.4 back office: order search, a lot of Twig, a lot of Doctrine, some PDF generation. It runs on a 4 vCPU virtual machine behind a load balancer, nginx in front of php-fpm with pm.max_children at 24. Nothing exotic. Which is exactly why I wanted to measure it, because if worker mode only helps applications that were already fast, it is not interesting.
Boot once, then answer
The whole idea fits in one sentence from the worker documentation: boot your application once and keep it in memory. Under FPM, every request pays for autoloading, container compilation checks, bundle boot, routing warmup, Doctrine metadata. Under worker mode, one PHP process boots the kernel and then sits in a loop calling frankenphp_handle_request(), handling request after request in the same process.
You do not have to write that loop yourself for Symfony. The Runtime component exists precisely so the bootstrapping logic is decoupled from how the application is served, and runtime/frankenphp-symfony supplies the FrankenPHP runner. Our public/index.php did not change at all:
<?php
// public/index.php
use AppKernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
What changes is one environment variable telling the Runtime component which runner to use, and one telling FrankenPHP which script is the worker:
docker run
-e FRANKENPHP_CONFIG="worker ./public/index.php"
-e APP_RUNTIME=Runtime\FrankenPhpSymfony\Runtime
-v $PWD:/app
-p 80:80 -p 443:443 -p 443:443/udp
dunglas/frankenphp
That is genuinely the whole setup, and it is why the experiment was cheap to start. The FRANKENPHP_CONFIG variable injects configuration under the frankenphp Caddy directive, so worker ./public/index.php is the short form of a worker block. Add a number after the path and you set the worker count: worker ./public/index.php 8.
The port mapping is not decoration either. FrankenPHP is Caddy underneath, so you get automatic HTTPS and HTTP/2 and HTTP/3 support without configuring any of it, and HTTP/3 is the reason 443 appears twice, once over UDP. In production we terminate TLS at the load balancer, so I set SERVER_NAME=:80 and turned all of that off. Nice feature, wrong layer for us.
There is a second way in, which I used for the first two days before I bothered with Docker. The project ships static binaries for Linux and macOS containing PHP 8.4 and most of the popular extensions, and frankenphp php-server --worker /path/to/index.php serves the current directory with a worker straight away. It is a good demo and a bad production choice for us, because the static build cannot load PHP extensions at runtime: you have to bundle them into the binary. We need pdo_mysql, intl and a couple of others, so we went back to the Debian image. That is also what the project recommends, since the musl-linked builds are slower for threaded PHP.
The config keys that mattered
Two defaults are worth knowing before you load test anything. FrankenPHP starts twice as many threads, and in worker mode twice as many workers, as you have CPUs. The performance page tells you to change them and gives the only sizing rule you really need: keep num_threads times memory_limit below available memory. With memory_limit at 256M on a 4 vCPU, 8 GB machine, the default of 8 threads was well inside that. I tried 12 and 16 and neither helped, because we were not thread starved, we were database bound past a certain point.
The full form of the config, from the Caddyfile reference, is what we shipped:
{
frankenphp {
num_threads 8
worker {
file /app/public/index.php
num 6
env APP_RUNTIME RuntimeFrankenPhpSymfonyRuntime
max_consecutive_failures 10
}
}
}
Every key there is documented: file, num, env, name, watch and max_consecutive_failures are the worker options, and num_threads, max_threads, max_wait_time and php_ini are the global ones. max_consecutive_failures defaults to 6: if the worker script exits non-zero that many times in quick succession, FrankenPHP gives up with too many consecutive failures rather than restarting forever. I raised it to 10 after a bad deploy took the whole container down in about four seconds, which is arguably the correct behaviour and definitely a surprising one at 11pm.
For local development, watch is the key that makes worker mode tolerable. Without it your code changes do nothing, because the kernel in memory is the one from when the container started. The default pattern when you write a bare watch covers .php, .yaml, .yml, .twig and .env files below the working directory, which is almost exactly what you want.
Memory climbed about 3 MB per request
The first load test looked wonderful for ninety seconds and then the container died. Second run, I watched RSS instead of latency: it climbed steadily, roughly 3 MB per request against the order search endpoint, and never came down.
This is the part nobody puts on a slide. Under FPM, a leak is invisible because the process dies after the request. In worker mode, every static property, every in-memory array cache, every object graph you attached to a long-lived service stays exactly where you left it. Symfony has an answer for this: services tagged kernel.reset get a method called between requests, and the documentation for the tag says plainly that it is for application servers that reuse the kernel between requests. The framework’s own services already carry it. Ours did not.
I found the offender by running bin/console debug:container --tag=kernel.reset, looking at what was on the list, and then grepping our own namespaces for private array properties that were written to during a request. There were eleven candidates. The one doing the damage was a rate lookup service that cached rows by lane identifier:
namespace AppPricing;
final class RateTableProvider
{
/** @var array<string, Rate[]> */
private array $cache = [];
public function __construct(private RateRepository $repository)
{
}
public function forLane(string $laneId): array
{
return $this->cache[$laneId] ??= $this->repository->findByLane($laneId);
}
}
Under FPM that is a per-request memoisation, and a good one. Under worker mode it is an unbounded cache of every lane the worker has ever seen, and we have tens of thousands of them. The fix is small:
namespace AppPricing;
+use SymfonyContractsServiceResetInterface;
+
-final class RateTableProvider
+final class RateTableProvider implements ResetInterface
{
/** @var array<string, Rate[]> */
private array $cache = [];
+ public function reset(): void
+ {
+ $this->cache = [];
+ }
Plus the tag in services.yaml. Doctrine was the other half: the entity manager keeps its identity map, and although Symfony can reset the manager service, it only works if the manager is declared lazy. Two of our custom managers were not, and the error message says so in as many words when the resetter tries.
After both fixes, RSS on a worker settled at 180 MB and stayed there across a twenty minute run. I would not have found either one without the Caddy metrics FrankenPHP exposes, specifically frankenphp_worker_request_count and frankenphp_worker_crashes, because before I understood the shape of the leak all I could see was that the container restarted sometimes.
Recycling is a tourniquet
The Symfony runner has an option called frankenphp_loop_max, the number of requests after which the worker restarts, and it defaults to 500. Custom worker scripts do the same thing with a MAX_REQUESTS environment variable read inside the loop. You can also restart every worker gracefully through the Caddy admin API with a POST to /frankenphp/workers/restart, which is what our deploy script does now.
I want to be clear that recycling is not a fix. It is a way to survive code you have not audited yet. We left the default of 500 in place, and with our leak gone it costs us one kernel boot per 500 requests, which is a rounding error. If I had left frankenphp_loop_max at 500 and never looked at RSS, the leak would have hidden behind it for months and then shown up as memory pressure on a busy Monday.
The numbers
Same virtual machine, same MySQL 8 instance, same dataset, 60 second k6 run at 40 virtual users against a mix of the order list, one detail page and one search query. FPM first, then FrankenPHP with 6 workers, after the leak was fixed.
| Measure | nginx + PHP-FPM | FrankenPHP worker mode |
|---|---|---|
| Requests per second | 148 | 402 |
| p50 latency | 91 ms | 31 ms |
| p95 latency | 214 ms | 79 ms |
| p99 latency | 488 ms | 206 ms |
| Resident memory, steady state | 1.4 GB across 24 children | 1.1 GB across 6 workers |
The honest caveat: about 35 ms of the p50 improvement is kernel boot that we simply stopped paying, and that number is a property of our container, not of FrankenPHP. An application with a smaller container and fewer bundles would see less. The p99 improvement is the one I care about, because it is mostly queueing that disappeared.
Why I would still leave some things on FPM
We moved the back office and we are leaving the public-facing API on FPM for now, and the reasons are not about performance. A leaking request under FPM costs one process. Under worker mode it costs every subsequent request that worker handles. Our API has two vendor libraries doing things with static state that I have not read line by line, and until I have, per-request isolation is worth more than 100 ms.
The other reason is operational. Everyone on the team can debug FPM at 2am. Worker mode adds a class of bug that only appears on request number 400, only in production, and only when two endpoints run in the same process. That is a real cost and it is paid by whoever is on call, not by whoever ran the benchmark.
Next thing on the list: getting max_threads configured with a real number instead of leaving it at the default, because our traffic has a 9am spike that the fixed thread count handles by queueing. I also want to check whether the leak hunt missed anything, so the plan is to drop frankenphp_loop_max to -1 on one instance for a week and watch what happens to memory.
Sources
- FrankenPHP worker documentation, v1.9.0: what worker mode does, the
FRANKENPHP_CONFIGworker directive, theMAX_REQUESTSpattern, the admin API restart endpoint and the two-workers-per-CPU default. - FrankenPHP configuration reference, v1.9.0: every Caddyfile key used above, including
num_threads,max_threads, theworkerblock options and theSERVER_NAMEenvironment variable. - FrankenPHP performance page, v1.9.0: the thread and worker sizing rule, and the advice to prefer glibc builds over musl in production.
- FrankenPHP metrics, v1.9.0: the worker metric names I used while chasing the leak.
- FrankenPHP v1.9.0 release notes: the version this write-up was tested against, tagged 18 July 2025.
- FrankenPHP README, v1.9.0: automatic HTTPS, HTTP/2 and HTTP/3 support, and the static binaries.
- Symfony Runtime component documentation: how
autoload_runtime.phpworks and howAPP_RUNTIMEselects a runner. - runtime/frankenphp-symfony: the FrankenPHP runner for Symfony and the
frankenphp_loop_maxoption with its default of 500.