Framework Competition — PHP-FPM Summary

The headline numbers from the measured nginx + PHP-FPM deployment: the cost of one pass over every endpoint, the plain routing request, the two data-access races, and what one request costs in memory. End-to-end HTTP over loopback, so the constant webserver overhead is included (see floor-http/floor-php in the dataset). The worker-recycling setting of the pool is stated with the server floor below, because it changes what each row contains.

PHP 8.3.33 · Linux 6.8.0-139-generic · php-fpm mode · OPcache: yes · 1000 iterations per run over 10 runs · charts show the median as a faint bar + dot with the fastest → p95 range, lower is better

Total response times
Total response times
Per-request memory — lightest, typical and heaviest endpoint
Per-request memory — lightest, typical and heaviest endpoint

Feature benchmarks

Routing

GET / — dispatches a plain request through the router and returns a rendered template — no database access.

Routing
ORM / Active Record

GET /items — loads one page of the 1,000 seeded rows through each framework's ORM / Active Record layer: 20 items plus a COUNT for the pagination total.

ORM / Active Record
REST API (JSON)

GET /api/items — serves the same page of items as a JSON response rather than HTML, which adds serialization to the ORM work.

REST API (JSON)

Latency by endpoint (ms, trimmed mean — lower is better)

RequestWorkloadAzeraLaravelSymfonySpiralCodeIgniterCakePHP
GET /no DB — routing + template only0.8104.512.107.931.971.53
GET /items20 of 1000 items (page 1, + COUNT)1.585.743.768.992.722.89
GET /items/11 item by id1.445.403.048.882.612.69
POST /items1 row upserted (sentinel #999999)1.645.403.928.962.712.85
GET /items-qb20 of 1000 items (page 1, + COUNT)1.405.242.668.512.692.30
GET /items-qb/11 item by id1.345.112.588.452.592.20
POST /items-qb1 row upserted (sentinel #999997)1.445.173.478.652.822.32
GET /api/items20 of 1000 items as JSON1.386.093.038.012.542.58
GET /api/items/11 item by id as JSON1.415.882.777.892.522.56
POST /api/items1 row upserted (sentinel #999998)1.475.283.608.032.642.71
GET /features/aopno DB — interceptor pipeline2.525.993.279.66——
GET /features/cacheCOUNT(*) of 1000 rows, cached 10s (miss = query)1.665.362.988.772.502.42
GET /features/logno DB — buffered log handlers1.234.521.958.06——
GET /features/retryno DB — retry policy1.224.621.978.06——
GET /features/pipelineno DB — middleware pipeline0.8114.561.988.07——
GET /features/db-events1 event row INSERTed per request1.755.433.169.002.792.64
GET /features/eventsno DB — in-process listeners1.735.122.418.672.691.94
GET /features/validationno DB — validator run0.8235.622.298.182.291.84
GET /features/configno DB — config lookup0.7654.591.968.071.971.40
GET /features/request-scopedno DB — scoped service resolve0.7704.591.988.071.951.41
GET /features/rate-limitno DB — cache-backed limiter0.7724.732.008.161.961.51

Server floor — real nginx + PHP-FPM with pm.max_requests=0: the pool never recycles its worker, so no process is spawned per request — what remains is the FastCGI handshake plus a minimal script. A hello-world endpoint that boots nothing but PHP (floor-php) and a static file through nginx (floor-http) measure exactly that cost — the floor every row stands on. Subtracting that floor leaves the framework's own per-request boot, which FPM still pays for every request even though its worker survives.