Common tuning strategies
BenchKit is not a tuned server. It turns OPcache on, caches configuration and routes, and otherwise leaves the serversideup/php defaults where they are.
That is on purpose. A benchmark tuned by whoever wrote it measures their tuning, and every result it produces is really a result about their opinions. Leaving it plain means the number you get first is a baseline you can beat — and beating it is where the learning is. Change one thing, run again, put the two side by side.
The worker pool
On the fpm-nginx and fpm-apache variations, PHP-FPM handles one request per worker for that request's whole duration — including any time it spends waiting on a database or an API. The pool size is therefore a hard ceiling on how many requests your server can have in flight, and no amount of CPU raises it.
You can see the ceiling on your own result. The /bench/io curve climbs in a straight line and then goes flat, and the flat part sits on a line drawn from arithmetic: workers ÷ delay. Where the curve bends is your worker count.
environment:
# How the pool is managed: ondemand, dynamic, or static
PHP_FPM_PM_CONTROL: "ondemand"
# The ceiling. A worker is a process, so this is bounded by memory
PHP_FPM_PM_MAX_CHILDREN: "20"
# Only used by dynamic and ondemand
PHP_FPM_PM_START_SERVERS: "2"
PHP_FPM_PM_MIN_SPARE_SERVERS: "1"
PHP_FPM_PM_MAX_SPARE_SERVERS: "3"
Size PHP_FPM_PM_MAX_CHILDREN from memory rather than from cores. A worker is a process holding a copy of your application — measured on this image, roughly 40 MB each — so twenty workers want about 800 MB before they serve anything. Set it past what your machine has and you trade a slow server for one the kernel kills.
A worked example that goes the wrong way
Here is a real change on a real machine, because it is more useful than a list of settings that "improve performance".
The observation: on a four-core box, response times were fine when idle and poor once busy. A single request came back in 13 ms; at 70% of what the machine could serve, the slowest 5% took 161 ms. That is the shape of a queue, and one likely cause is a pool that starts workers on demand — every time traffic outruns the few spare workers, PHP-FPM forks a new one and boots the application before it can answer anything.
So the obvious fix is a pool that stays warm:
environment:
PHP_FPM_PM_CONTROL: "static"
It made things worse.
ondemand | static | |
|---|---|---|
| Response time | 26 ms | 83 ms |
| Slowest 5% | 141 ms | 173 ms |
| Max throughput | 437 req/s | 462 req/s |
Throughput barely moved and the median response time tripled. The reason was in the same result's environment panel: the machine had 3.9 GB of memory and was already using swap. static forks all twenty workers at boot and keeps them resident, and 800 MB of resident PHP was memory that box did not have. ondemand was the right setting for it all along.
OPcache
BenchKit turns OPcache on by default, because practically no production host runs without it and a benchmark measuring PHP recompiling your application on every request is measuring nothing anyone deploys.
environment:
PHP_OPCACHE_ENABLE: "1"
# Off in production: PHP then never stats your files for changes
PHP_OPCACHE_VALIDATE_TIMESTAMPS: "0"
PHP_OPCACHE_MEMORY_CONSUMPTION: "256"
PHP_OPCACHE_MAX_ACCELERATED_FILES: "20000"
# Rarely helps a typical web request — worth measuring rather than assuming
PHP_OPCACHE_JIT: "tracing"
PHP_OPCACHE_JIT_BUFFER_SIZE: "64M"
JIT is the one most often expected to help and most often does not. A typical Laravel request spends its time on I/O, autoloading and array work rather than in the kind of tight numeric loop JIT is good at. Turn it on, run again, and see — that is a two-minute experiment and a better answer than anybody's opinion.
Worker mode: FrankenPHP and Octane
The frankenphp variation can run in worker mode, where the framework boots once and stays in memory across requests instead of booting per request. That removes the fixed cost of bootstrapping Laravel from every single request, which is most of what a small JSON response costs.
It is a different execution model rather than a faster version of the same one, and it comes with the constraints of long-lived processes — no request state in singletons or statics. See FrankenPHP for how to run it and what to watch.
The database
BenchKit uses SQLite unless you point it elsewhere. SQLite runs inside the PHP process, so it has no connection cost and none of the contention behaviour of a database server — which makes it a poor stand-in if your application talks to MySQL or Postgres.
See Adding a real database for compose recipes. The gallery lets you filter by engine, because a result measured against SQLite and one measured against Postgres are not on the same axis.
How to tell whether it helped
Run, change one thing, run again, and open the two runs side by side in the compare view.
Watch the response time and the throughput separately. They move independently and they can move in opposite directions — the example above bought 25 req/s and paid 57 ms for it. Which of those you want depends on what you are building, and the benchmark's job is to show you the trade rather than to pick for you.