pm.max_children Is a Guess
Every PHP-FPM config starts with a number someone guessed. Fine for one app, but on a box running fifty pools that share the same RAM, it's how you starve some sites and pay for idle workers on others. So I built a tool that measures what workers actually cost and sizes the ceilings from that.
Almost every PHP-FPM setup I've ever touched starts with the same back-of-the-envelope math: I've got 8 GB of RAM, a worker uses about 100 MB, so I can run around 80 workers. Set pm.max_children = 80, commit, move on.
It's a fine guess. For one app.
Then the box grows a second site, and a third, and eventually it's running ten, or fifty, or a hundred pools, and every one of them is still carrying a number someone picked the same way. Except now they're not each sizing against 8 GB. They're all fighting over the same 8 GB. And their workers don't cost the same: the shop's workers sit at 180 MB with the full framework and a fat cart in memory, while the marketing blog's sit at 40 MB. One static number can't be right for all of them, and nobody goes back to check.
We got away with it because RAM was cheap
Here's the honest part: guessing worked. For years the easy fix when a box felt slow was to give it more memory and leave plenty of headroom. Overprovisioning was cheaper than the hour it takes to tune anything, so we overprovisioned and got on with our lives.
But headroom you're paying for and not using isn't free. If you're renting the hardware anyway, you might as well use it properly. And "properly" doesn't mean squeezing out every last megabyte; it means balancing three things at once:
Utilisation: the machine you already pay for actually doing work.
Stability: no swapping, no latency spikes, no OOM killer at 3 AM.
Capacity: workers actually free when the traffic shows up.
Lean too conservative and you rent RAM to sit idle while requests queue for a worker. Too aggressive and you're one traffic spike from the OOM killer. Too static and today's right answer is next week's wrong one. It's a balance, and a single hand-picked number doesn't hold it for long.
The thing nobody sizes
PHP-FPM already manages workers for you. With pm = dynamic or ondemand it starts and stops them as load comes and goes. That part works. So what's left to tune?
The ceiling. pm.max_children is the number of workers a pool is allowed, and everything FPM does happens underneath it. Two different questions, and it's worth keeping them apart:
PHP-FPM decides how many of a pool's allowed workers should be running right now.
The ceiling decides how many that pool is allowed in the first place.
FPM manages the workers inside the box. Almost nobody sizes the box. If it's wrong, FPM is very efficiently managing workers inside the wrong one.
So measure it
I got tired of guessing, so I built fpm-tune to stop. It watches what each pool's workers actually cost (real resident memory, per pool, measured off the live status page), divides the one pile of RAM between them by what they really use, and, if you let it, writes the ceilings back and reloads PHP-FPM.
The measuring is the whole point, because the per-worker cost is where every hand-written config goes wrong. Take the box this post is served from. cbox.dk runs on Statamic, and right now its PHP-FPM is two pools: one sitting around 100 MB per warm worker, the other around 140 MB, sharing a single 512 MB opcache between them. Idle, at 3 AM, those same workers drop to roughly 35 MB.
That gap is the whole trap. Size on the 35 MB idle figure and the box falls over the first time real traffic warms the workers up to 140. And the PHP memory calculators everyone reaches for don't save you: they plug in 50 MB, maybe 100, per worker and call it done, when a real Laravel or Statamic worker runs 50 to 100 MB heavier than that. There's no constant to look up. The true number swings with the framework, the packages installed, and what a given request actually does, which is exactly why fpm-tune sizes on the warm peak it measures rather than a figure you paste in once.
Now put a few sites on one box and the pools stop looking alike. A media-heavy shop can sit three or four times higher than a docs site, so one static pm.max_children either starves the expensive pool or wastes the budget on the cheap one, and no hand-written number knows the ratio until something falls over. There's only one pile of RAM, so fpm-tune treats it that way: an expensive pool gets fewer workers, and a busy pool can borrow headroom from an idle neighbour and give it back when the traffic moves. That last part is the bit a per-pool calculator simply can't do.
And when everyone needs more and the budget's gone, it says so plainly: the host is out of capacity, and no amount of re-tuning will conjure memory that isn't there. That honesty matters more than a clever number.
It writes production config, so it's paranoid about it
Reading memory is safe. Writing pm.max_children to a live pool and reloading the master is not, and I didn't want a tool that's cavalier about it. So it earns trust one step at a time.
plan shows you what it would do and changes nothing. serve watches and recommends, still touching nothing. Leave it running as an adviser for a day or a week and read what it would do against your real traffic. Only when you add --apply does it act. And when it does, nothing reaches PHP-FPM until it's been validated against a throwaway copy of the config; the change lands as one atomic write and a graceful reload with no dropped requests, and rolls back if the master doesn't come back. If its own file ever stops php-fpm from starting, it takes that file back out.
If you've read "Your PHP Container Metrics Are Lying to You", you'll recognise the budget logic: it reads the master's cgroup, not the machine, so it doesn't hand a container memory the host doesn't actually give it.
Static, dynamic, ondemand: it sizes the box, not the mode
One thing I was deliberate about: fpm-tune sizes pm.max_children inside whatever pm mode you chose, and never rewrites the mode itself. The right mode depends on latency targets and memory preferences the tool can't measure, and honestly, PHP-FPM's own scaling is good. The job is to get the ceiling right underneath it, not to replace it.
But it will tell you when your measured shape doesn't fit the mode. Two cases it's confident about:
A static pool holding idle workers. static runs every allowed worker at all times, so if the busiest moment you've ever had left half of them unused, you're paying to keep memory resident that nothing touched. dynamic or ondemand would hand it back between requests, unless you're keeping them warm for latency on purpose, which is a perfectly good reason to ignore the hint.
An ondemand pool that's queuing. ondemand spawns each worker on demand and lets idle ones die, so a burst pays a cold start on every worker. A warm floor (that's what dynamic's
start_servers/min_spare_serversare) absorbs the burst instead.
It won't push a busy dynamic pool toward static, even though that's sometimes the right call, because telling "steadily maxed out" from "spiked once" needs a signal it doesn't keep, and a confident-sounding wrong suggestion is worse than silence. The suggestion prints under the plan and is never, ever written. Your call.
It's beta, and I won't pretend otherwise
fpm-tune is beta. It's had a hard time on real hosts: a soak suite, chaos tests that yank a pool out mid-reload, and mutation testing that deletes each safety guard and requires the test suite to fail. But it writes production config, so run it advisory-first, read what it recommends, and let it act only once its numbers have earned it.
If you run PHP-FPM on a box with more than one pool (a Forge or Ploi server, a container host, anything sharing RAM), it's worth an afternoon:
curl -fsSL https://raw.githubusercontent.com/cboxdk/fpm-tune/main/install.sh | sh
fpm-tune plan
That reads your host and prints what it would change, and why. It won't touch a thing. The docs take it from there, including a start-to-finish recipe if you're on Forge or Ploi.
We've all been guessing at this number for years. It was fine while RAM was the cheap fix. It isn't the cheap fix anymore.