FrankenPHP
FrankenPHP is a PHP application server built on Caddy. It serves PHP and HTTP from a single process, so there is no PHP-FPM pool and no separate web server to supervise.
Its headline feature is worker mode: the framework boots once and stays in memory, instead of bootstrapping on every request. BenchKit can run the same image both ways, which is the cleanest comparison the tool can make.
Two modes, one image
| Mode | What happens per request | How you start it |
|---|---|---|
| Classic | The framework boots, serves, and tears down, the way PHP-FPM does | Default command |
| Worker | The framework is already booted and stays resident | php artisan octane:start |
docker run -p 80:8080 \
-v benchkit-runs:/var/www/html/storage/app/runs \
serversideup/benchkit-laravel:frankenphp
docker run -p 80:8080 \
-v benchkit-runs:/var/www/html/storage/app/runs \
serversideup/benchkit-laravel:frankenphp \
php artisan octane:start --server=frankenphp --host=0.0.0.0 --port=8080
BenchKit detects which mode served the run and labels the result. A worker mode run and a classic run are never mistaken for each other.
Health checks in worker mode
The image's built-in health check is wired for the classic setup. Octane starts its own server with its own Caddy configuration, so that check starts failing once worker mode takes over. The image ships a healthcheck-octane script for exactly this case.
If Docker Compose or an orchestrator is watching container health, point the check at that script:
services:
benchkit:
image: serversideup/benchkit-laravel:frankenphp
ports:
- "80:8080"
command: ["php", "artisan", "octane:start", "--server=frankenphp", "--host=0.0.0.0", "--port=8080"]
healthcheck:
test: ["CMD", "healthcheck-octane"]
start_period: 10s
volumes:
- benchkit-runs:/var/www/html/storage/app/runs
volumes:
benchkit-runs:
With a plain docker run, nothing acts on health status. The container shows as unhealthy in docker ps, which you can ignore or silence with --no-healthcheck.
Reading the comparison fairly
Worker mode's saving is the framework bootstrap, and that is a fixed amount of work per request. So the saving looks largest on fast, CPU-bound routes, and it is a smaller share of a request that spends most of its time waiting on a database or an API.
That is a claim about proportion, and it is easy to get wrong, because two other things move at the same time:
PHP-FPM's pool size caps concurrency. Under FPM a request occupies a worker for its whole duration, including time spent waiting. On the simulated I/O route, a pool of 20 workers cannot exceed a fixed ceiling however fast the hardware is. If your connection count is above pm.max_children, you are measuring the pool rather than the framework. BenchKit detects the pool size and flags the run when this happens.
Neither side is tuned by default. A stock FPM pool against a stock FrankenPHP is not a verdict on either one. Compare tuned against tuned, and say which tuning you used.
/bench/io curve on the FPM side. If it flattens early, the worker pool is what you measured rather than the server — see Common tuning strategies. The most common way to produce a dramatic and wrong result is to compare a tuned worker mode against a default pool.