Skip to main content

Benchmark: PHP-FPM, WarmPHP and FrankenPHP

The same user journey, run on the builds of gluo-framework-comparison, by five servers:

  • fpm: nginx and PHP-FPM, which boots the application on every request.
  • frankenphp-classic: FrankenPHP without its worker mode: like FPM, it runs the project's front controller on every request, without nginx.
  • worker: nginx and WarmPHP, one booted PHP process per worker.
  • prefork: the same with -mode prefork: one boot, forked into the workers.
  • frankenphp: FrankenPHP's worker mode, without nginx.

There are two suites:

  • fullstack: each framework the way it builds a whole application (server-rendered pages, or Inertia for Laravel), against Gluo.
  • api: each framework as a JSON API in front of Gluo's SPA, the same contract as Gluo's API. This one compares like with like: the same endpoints, the same JSON, a JWT everywhere.

The conclusions, per server and per framework, are in analysis.md.

The projects​

BuildFrameworkHow it runs in the worker
frameworks/fullstack/symfony-officialSymfony 8.1, Doctrine, Twig, form logincompat/symfony.php: one kernel
frameworks/fullstack/cakephpCakePHP 5.4, Authentication plugincompat/cakephp.php: one application, a new DI container per request
frameworks/fullstack/codeigniterCodeIgniter 4.7, Shieldcompat/codeigniter.php: its own worker mode
frameworks/fullstack/laravelLaravel 13, Inertia (React), Fortifycompat/laravel-fresh.php: a new application per request; compat/laravel-shared.php: one application
frameworks/gluoGluo 7.0, JSON API with JWT, React SPAcompat/gluo.php: one ByJG\Gluo\Http\Application
frameworks/api/symfonySymfony 8.1, Doctrine, lexik JWTcompat/symfony.php
frameworks/api/cakephpCakePHP 5.4, Authentication plugin (Jwt)compat/cakephp.php
frameworks/api/codeigniterCodeIgniter 4.7, Shield JWTcompat/codeigniter.php
frameworks/api/laravelLaravel 13, Passport (JWT access tokens)compat/laravel-fresh.php and compat/laravel-shared.php

The boot files are the same for both suites, and for FrankenPHP's worker mode: they only need the project's root. docs/compatibility.md explains each one. No application is changed for the benchmark.

The journey​

Every iteration is one visitor, with a new browser (cookies, connections): log in as the seeded admin, then a whole CRUD session. A journey counts if every response passes the benchmark's checks: the status, the redirect to the expected page, a list with products; anything else is an error. After each run, outside the timed work, the database is checked too: each edited product has one of the prices sent for it, every new product is there with its price, and nothing else appeared. A failed check fails the run. Each framework does every step the way it does it; no application is changed.

#Stepfullstackapi (and Gluo)
1pagethe login page— (a static file of the SPA)
2loginthe login form, with its CSRF token, and the redirect it answersPOST /login, returns a JWT
3list1the product list, page 1GET /product?page=0&size=50
4list2the product list, page 2GET /product?page=1&size=50
5getthe edit page of a random product (1–100)GET /product/{id}
6editthe edit form posted with a new price, and the redirect it answersPUT /product/{id}, a new price
7addthe new-product form, posted, and the redirect it answersPOST /product
8relist1page 1 againpage 1 again
9relist2page 2 againpage 2 again

The pages are each framework's own:

Buildlist pagesedit pageafter a save
Symfony/product (no paging: the whole table)/product/{id}/editthe list
CakePHP/products?page=1&limit=50, page=2/products/edit/{id}/
CodeIgniter/products?page=1, ?page=2/products/edit/{id}the list
Laravel/products (no paging: the whole table)/products/{id}/editthe list
every API, Gluo/product?page=0&size=50, page=1——

So in the full-stack suite, edit and add include a list page (the redirect), and get is an HTML form: compare their steps with each other, not with the API's.

The data. Before each run (each build, server and OPcache setting), the build is reset with its own commands (tables recreated, seeds run), then one SQL statement adds products 6 to 100: 100 products, ids from 1. The run stops if the count or the first id is wrong. Each journey adds one product, so a run ends with about 205.

BuildReset
Symfonydoctrine:schema:drop --force --full-database, doctrine:migrations:migrate, doctrine:fixtures:load
CakePHPmigrations rollback -t 0, migrations migrate, seeds run -q -f
CodeIgniterspark migrate:refresh --all -f, spark db:seed ProductSeeder, and the files of writable/debugbar and writable/session
Laravelartisan migrate:fresh --seed --force, and storage/inertia-devtools
Gluogluo --env=dev migrate:reset

Symfony's fixtures are loaded into a recreated schema, not with --purge-with-truncate: MySQL commits on TRUNCATE, which breaks Doctrine's transaction.

Two development tools write a file per request, and they stay on, as the projects are configured; only their folders are cleared. Inertia's DevTools matters most: on every request it rewrites an index of all its entries, under a lock. With 11,000 entries that is 3.9 MB per request, and full-stack Laravel ran three times slower, with results that changed by up to 221 % between rounds.

How each server runs​

fpm, worker and prefork run in one container of the same Docker image, byjg/php:8.5-fpm-nginx (Alpine, PHP 8.5.12 NTS, its php, php-fpm and nginx), with the same user, mounts and environment, nginx in front on 127.0.0.1:18811, and 4 PHP processes:

  • fpm: php-fpm with pm = static and 4 children, on a Unix socket; nginx serves the project's public folder and falls back to its front controller (try_files $uri /index.php?...), as the image does with NGINX_ROOT and PHP_CONTROLLER: public/index.php (Symfony, CodeIgniter, Laravel), webroot/index.php (CakePHP), Gluo's public/app.php.
  • worker: the gateway (built static, CGO_ENABLED=0) on 127.0.0.1:18812, with -workers 4 and the boot file of compat/; its workers are the image's php, as its child processes (the frames go through plain pipes). nginx proxies to it over HTTP/1.1 with keep-alive.
  • prefork: the same with -mode prefork; the workers are forks of one booted master, and talk to the gateway over a Unix socket.

The nginx configuration is the same for the three except that location: the image's defaults (worker processes, mime types), no access log, client_max_body_size 1m (the gateway's -max-body). The image's start scripts are not used: they listen on port 80 as the image's user, and here nginx and PHP run as the user that owns the projects (they write their caches), on the host network (the databases are on 127.0.0.1). So the benchmark writes the FPM pool and the nginx configuration itself, with their temporary files in a folder of its own.

frankenphp runs in its own image, benchmark/frankenphp/Dockerfile: dunglas/frankenphp:1-php8.5 (Debian, PHP 8.5.11 ZTS) with the extensions the projects need, and a Caddyfile of its own: 4 PHP threads, no admin API, no HTTPS, no compression. Caddy also serves the static files, so nginx is not there. Its worker script is compat/frankenphp.php, which runs the same boot files of compat/, with 4 workers.

frankenphp-classic is the same image and Caddyfile without the worker: php_server with the project's front controller as its fallback (try_files {path} /index.php, /app.php for Gluo), the files FPM runs. So it boots the application on every request, as FPM does, and its difference from frankenphp is only the worker mode: the same PHP build, the same server, the same memory reading without nginx.

The two PHP builds are not equally fast, which matters for the comparison. On this machine, in the same CPU test, one process. All values are elapsed milliseconds (ms); lower is better:

PHP buildbcrypt, cost 12 (ms ↓)Integer loop (ms ↓)md5 (ms ↓)
byjg (Alpine, NTS)170.5 ms194 ms394 ms
FrankenPHP (Debian, ZTS)161.0 ms209 ms379 ms

So FrankenPHP's login is faster whatever the server does, in both its modes: 2 to 4 % against FPM's in this benchmark (frankenphp-classic against fpm, which do the same work).

The memory column is what docker stats shows for that container after the run (without the page cache): nginx, and PHP-FPM or the gateway with its workers; or FrankenPHP alone, in both modes. nginx with this configuration and nothing behind it takes 18.6 MiB on this machine, because it starts one process per CPU (22); with 4 processes, 6.3 MiB.

4 clients run the journeys concurrently, after a warm-up of 5 % of the journeys. The runs of a build go in a random order, so no server is always the first after the database starts.

OPcache on and off: -d opcache.enable=0|1 -d opcache.enable_cli=0|1. Xdebug is off (-d xdebug.mode=off): the byjg image loads it, in develop mode.

Settings, given as environment variables (no file changed). Symfony's Dotenv only reads them from $_SERVER and $_ENV: FPM keeps them (clear_env = no) and runs with variables_order=EGPCS; the workers put the environment in $_SERVER themselves, as the CLI does. An API build runs with the settings of its full-stack sibling:

BuildEnvironment
SymfonyAPP_ENV=prod APP_DEBUG=0
CakePHPDEBUG=false APP_FULL_BASE_URL=http://localhost:8084 (API: :9083; CakePHP requires it in production)
CodeIgniternone: development, as the projects are configured
LaravelCACHE_STORE=array, and for the full-stack build DB_HOST=127.0.0.1 DB_PORT=3307 (its .env is Sail's); the array cache removes Fortify's limit of 5 logins per minute, which the database cache keeps across requests
GluoAPP_ENV=dev

Laravel with a new application per request runs with -max-requests 30: each application stays in memory with its database connection (see compatibility.md), and MySQL accepts 151 connections.

Every build hashes passwords with bcrypt: cost 12, except Symfony, whose auto hasher uses cost 13 (twice the work). Gluo's users moved from unsalted SHA-1 to bcrypt in Gluo 7.0 (byjg/authuser), so its login is no longer cheaper than the others'.

Results​

The gateway's logs are on in every worker and prefork run: a record per request, with PHP's metrics. They cost about 40 to 70 µs per request. On Gluo's journey, measured apart with 300 journeys and the median of 5 rounds, with the logs on and off, journeys/s did not change, and the 7 steps took 1.1 to 1.9 % longer, within the difference between rounds.

100 journeys per run, one build at a time, with only that build's database up: 102 runs, 3 rounds each, 30,600 journeys, none failed the checks. Each row is the complete round selected by its median journeys/s across the three rounds; its step times and memory are from that same round, not independent medians. The spread column of benchmark/results.tsv is (maximum journeys/s − minimum journeys/s) / median journeys/s × 100: at most 11 %, and 1 % at the median, so treat a difference under about 5 % as too close to call; three short rounds are not a significance test. Each round is also in benchmark/results-round<n>.tsv. Intel Core Ultra 9 185H (22 CPUs), 30 GB, Docker, every build on MySQL 8.4. All Docker containers were removed before the first round, and the machine-wide cpu column reads 16 to 19 % in every run. A journey's login is bcrypt, about half of it or more, so the "7 steps" tables below show the frameworks and the servers better.

A few rows vary more than the rest. 92 of the 102 rows differ by 3 % or less between rounds. The exceptions are full-stack CakePHP on prefork with OPcache, 11 % (14.6, 13.7 and 15.2 journeys/s), the Symfony API on prefork with OPcache, 8 %, and the CakePHP API on classic FrankenPHP and the Laravel API (shared application) on prefork, 7 %. Each of those is one run, not a whole round: the other runs of the same rounds are steady. The step that moves is edit, the first write (full-stack CakePHP on prefork took 26, 50 and 17 ms for it; the Symfony API on prefork, 12, 12 and 42), and the same step varies inside rows whose journeys/s do not: the Symfony API on FrankenPHP with OPcache took 11, 23 and 26 ms for it in the three rounds, so its 43 ms for the 7 steps, against 35 on prefork and 31 on FrankenPHP without OPcache, is that step, not the server.

Reading the results​

  • Throughput (journeys/s): higher is better. One journey is the complete login and CRUD sequence, not one HTTP request. Throughput is the aggregate across four concurrent clients.
  • Post-login time (ms): lower is better. The sum of the average client-observed times of the seven operations from list1 to relist2; it excludes login and the login page.
  • Container memory (MiB): lower is better for the same workload. One MiB is 1,048,576 bytes. This is the end-of-run reading for the whole container with four PHP workers, not memory per request or peak memory. PHP-FPM and WarmPHP include nginx; FrankenPHP serves HTTP itself.
  • ↑ means higher is better; ↓ means lower is better. Small differences may be noise; see the variation between rounds above. These measurements do not establish maximum server capacity.

Journeys per second (higher is better)​

api

OPcache on — Throughput (journeys/s ↑); four PHP workers.

BuildPHP-FPM + nginxFrankenPHP classicWarmPHP worker + nginxWarmPHP prefork + nginxFrankenPHP worker
Gluo16.817.318.218.119.5
Symfony9.39.410.110.110.4
CakePHP17.117.818.518.519.7
CodeIgniter15.715.816.216.316.8
Laravel, new app13.213.610.612.914.2
Laravel, shared app13.213.615.615.716.7

OPcache off — Throughput (journeys/s ↑); four PHP workers.

BuildPHP-FPM + nginxFrankenPHP classicWarmPHP worker + nginxWarmPHP prefork + nginxFrankenPHP worker
Gluo10.510.718.118.119.4
Symfony4.64.610.110.110.7
CakePHP7.77.818.418.419.7
CodeIgniter8.18.116.116.116.6
Laravel, new app4.34.311.311.112.4
Laravel, shared app4.34.315.715.616.5

fullstack (Gluo is the API above)

OPcache on — Throughput (journeys/s ↑); four PHP workers.

BuildPHP-FPM + nginxFrankenPHP classicWarmPHP worker + nginxWarmPHP prefork + nginxFrankenPHP worker
Symfony8.18.29.29.29.8
CakePHP13.914.215.014.616.4
CodeIgniter9.99.911.011.311.3
Laravel, new app5.65.64.75.45.8
Laravel, shared app5.65.66.16.16.3

OPcache off — Throughput (journeys/s ↑); four PHP workers.

BuildPHP-FPM + nginxFrankenPHP classicWarmPHP worker + nginxWarmPHP prefork + nginxFrankenPHP worker
Symfony3.13.19.19.19.7
CakePHP5.35.114.814.816.1
CodeIgniter4.74.710.710.710.9
Laravel, new app2.32.34.94.95.2
Laravel, shared app2.32.36.06.06.2

The 7 steps after the login, ms (lower is better)​

The combined latency of the seven operations after the login: the sum of the average time of list1 to relist2, as the client sees it, without bcrypt. It includes HTTP, the database, the response and, in the full-stack suite, the redirects and the new-product form: 9 or 10 requests there, 7 in the API suite.

OPcache on — Post-login time (ms ↓); four PHP workers.

BuildPHP-FPM + nginxFrankenPHP classicWarmPHP worker + nginxWarmPHP prefork + nginxFrankenPHP worker
Gluo4243272623
Symfony API6571353543
CakePHP API5352363732
CodeIgniter API7075636162
Laravel API, new app87871379478
Laravel API, shared8787474642
Symfony full-stack108118656557
CakePHP full-stack9698778666
CodeIgniter full-stack175180141136140
Laravel full-stack, new app270279360294259
Laravel full-stack, shared270279226220217

OPcache off — Post-login time (ms ↓); four PHP workers.

BuildPHP-FPM + nginxFrankenPHP classicWarmPHP worker + nginxWarmPHP prefork + nginxFrankenPHP worker
Gluo164163262724
Symfony API449449353531
CakePHP API301301373833
CodeIgniter API277282626464
Laravel API, new app641634130135110
Laravel API, shared641634464744
Symfony full-stack743750676760
CakePHP full-stack467474797971
CodeIgniter full-stack511524147150150
Laravel full-stack, new app10401037352356319
Laravel full-stack, shared10401037230230228

Memory, MiB (lower is better)​

The whole container, with 4 PHP workers: one docker stats reading at the end of the run, not a peak or a steady state. FPM, worker and prefork include nginx (18.6 MiB here); FrankenPHP, in both modes, is alone.

OPcache on — Container memory (MiB ↓); four PHP workers.

BuildPHP-FPM + nginxFrankenPHP classicWarmPHP worker + nginxWarmPHP prefork + nginxFrankenPHP worker
Gluo48441085951
Symfony API60591618567
CakePHP API56511619662
CodeIgniter API56511277260
Laravel API, new app7371146104108
Laravel API, shared7371221107102
Symfony full-stack676620811882
CakePHP full-stack615617310965
CodeIgniter full-stack70681498979
Laravel full-stack, new app9996244182206
Laravel full-stack, shared9996259137134

OPcache off — Container memory (MiB ↓); four PHP workers.

BuildPHP-FPM + nginxFrankenPHP classicWarmPHP worker + nginxWarmPHP prefork + nginxFrankenPHP worker
Gluo5955926357
Symfony API979314211299
CakePHP API767514211078
CodeIgniter API76721088074
Laravel API, new app143139114166179
Laravel API, shared143139181114146
Symfony full-stack106101184152125
CakePHP full-stack857515212089
CodeIgniter full-stack9187134104101
Laravel full-stack, new app170167275243294
Laravel full-stack, shared170167219149184

Laravel with a new application per request is measured at whatever point -max-requests 30 left it, so its memory moves between rounds (146 to 218 MiB, API worker with OPcache; 91 to 112 MiB on prefork).

Selected step timings, OPcache on​

WarmPHP prefork and FrankenPHP worker, four PHP workers. All times are means in milliseconds (ms; lower is better). "page" is the login page; — means that step is not performed in the API suite. list1 and list2 request product lists (50 products in the API; full-stack paging varies above). The post-login total includes list1, list2, get, edit, add, relist1 and relist2. The two relist columns are omitted here, so the displayed individual steps do not sum to the total. Login and the login page are excluded from that total.

BuildServerpage (ms ↓)login (ms ↓)list1 (ms ↓)list2 (ms ↓)get (ms ↓)edit (ms ↓)add (ms ↓)Post-login total (ms ↓)
GluoWarmPHP prefork + nginx—1941.71.60.99.59.226
FrankenPHP worker—1811.41.20.78.88.723
Symfony APIWarmPHP prefork + nginx—3613.22.91.812.39.835
FrankenPHP worker—3412.72.51.623.38.843
CakePHP APIWarmPHP prefork + nginx—1793.53.01.814.010.437
FrankenPHP worker—1702.72.51.512.09.632
CodeIgniter APIWarmPHP prefork + nginx—1857.56.55.316.913.761
FrankenPHP worker—1768.36.65.915.913.462
Laravel API, sharedWarmPHP prefork + nginx—2095.45.33.111.211.346
FrankenPHP worker—1974.94.52.610.510.242
Symfony full-stackWarmPHP prefork + nginx2.43676.66.14.117.718.465
FrankenPHP worker2.13475.25.03.516.816.457
CakePHP full-stackWarmPHP prefork + nginx2.31869.79.23.126.119.286
FrankenPHP worker1.81768.27.82.716.615.066
CodeIgniter full-stackWarmPHP prefork + nginx5.521216.615.97.128.635.5136
FrankenPHP worker5.820616.316.47.930.537.1140
Laravel full-stack, sharedWarmPHP prefork + nginx14.641730.130.19.244.444.6220
FrankenPHP worker13.340429.830.08.243.043.5217

Every step of every run, with p50, p99 and the CPU, is in benchmark/results.tsv. Here p50 is the median journey duration and p99 is the 99th-percentile duration, both in ms (lower is better). With this benchmark's 100-sample calculation, p99 is the slowest journey. cpu and steal are machine-wide percentages used to spot interference, not server rankings.

Screenshots​

Taken after each build's run: the login page and the product list, with the prices the journeys edited and the Bench … products they added. Each list keeps its own order: Symfony and CakePHP by id, CodeIgniter and Gluo by name, Laravel newest first. The API builds have none: they all serve Gluo's SPA, unchanged, so their pages look like Gluo's.

The pages are what each framework gives. Symfony's are make:crud's templates, unstyled (the sky-blue background is the AssetMapper recipe's app.css); CodeIgniter has no view generator, so its two views are written by hand in plain HTML; CakePHP's bake templates come with its CSS; Laravel's are the React starter kit's. The screenshots use the benchmark's settings: Symfony in prod, CodeIgniter in development, which shows its debug toolbar button (bottom right).

BuildLoginProducts
Symfonyloginproducts
CakePHPloginproducts
CodeIgniterloginproducts
Laravelloginproducts
Gluologinproducts

Reproducing​

From fresh clones of both repositories, side by side (COMPAT_DIR points elsewhere). Each build has its own MySQL in Docker, and the ports do not overlap.

fullstack and Gluo — migrated and seeded as compatibility.md shows.

api — in gluo-framework-comparison (each guide in its docs/api-*.md has the details):

cd frameworks/api/laravel && composer install && cp .env.example .env && php artisan key:generate \
&& php artisan passport:keys && docker compose up -d --wait && php artisan migrate:fresh --seed && cd -
cd frameworks/api/symfony && composer install && php bin/console lexik:jwt:generate-keypair \
&& docker compose up -d --wait && php bin/console doctrine:migrations:migrate -n \
&& php bin/console doctrine:fixtures:load -n && cd -
cd frameworks/api/cakephp && composer install && docker compose up -d --wait \
&& bin/cake migrations migrate && bin/cake seeds run -q && cd -
cd frameworks/api/codeigniter && composer install && cp .env.example .env && docker compose up -d --wait \
&& php spark migrate --all && php spark db:seed ProductSeeder && cd -

Optionally, check each API against the contract of Gluo's SPA (it needs the API served, e.g. php artisan serve --port=9081):

php frameworks/api/contract-test.php http://localhost:9081 http://localhost:7081

Then, from this repository:

BENCH_JOURNEYS=10 BENCH_ONLY=gluo make benchmark # a smoke run of one build; its database must be up
BENCH_CLIENTS=4 BENCH_JOURNEYS=100 benchmark/rounds.sh 3 # these results: about 1 h 45
python3 benchmark/median.py # benchmark/results.tsv, from the rounds

rounds.sh erases the earlier rounds, starts each build's database, waits for it, runs the build, stops it, and keeps each round in benchmark/results-round<n>.tsv. It stops at the first failure, and median.py refuses rounds that are not complete. Run it on a quiet machine, and stop it from sleeping: the cpu and steal columns help to spot a disturbed run.

VariableDefault
BENCH_JOURNEYS3000journeys per run
BENCH_CLIENTS4concurrent clients, and PHP processes on each server
BENCH_SUITEfullstack or api; Gluo runs in both
BENCH_ONLYonly the targets whose name contains it (cakephp, api-laravel...)
BENCH_SERVERSfpm,frankenphp-classic,worker,prefork,frankenphpthe servers to run, comma separated
BENCH_PHP_INIxdebug.mode=offPHP settings for every server, space separated (name=value); the image loads Xdebug
BENCH_IMAGEbyjg/php:8.5-fpm-nginxthe image of FPM and the gateway: php, php-fpm and nginx
BENCH_GATEWAYa gateway binary already built (default: go build)
COMPAT_DIR../gluo-framework-comparisonthe comparison repository (the compatibility tests read it too)

The benchmark sets two more for FrankenPHP's worker script (compat/frankenphp.php): WARMPHP_BOOT, the boot file of compat/ it runs, and WARMPHP_MAX_REQUESTS, the build's -max-requests (it ends the script after that many requests, and FrankenPHP starts it again).

The benchmark resets the databases before every run (see The data): it erases what they had. It writes to the projects' ignored folders and databases: sessions (Laravel's sessions table, CodeIgniter's writable/session), Passport's tokens (oauth_access_tokens) and logs. It also rewrites the tracked config/Migrations/schema-dump-default.lock of both CakePHP builds: restore it with git checkout afterwards.