PHP-FPM pm.max_children: The Math Nobody Writes Down
Everyone cargo-cults 50 workers. Here's how I actually calculate pm.max_children for a Laravel app on a single VM, and why getting it wrong kills you quietly.
Every few months I inherit a server someone else configured, and pm.max_children is either 10 (starving) or 500 (thrashing). Nobody got fired for either number because the app "works" — until traffic spikes or a slow query holds workers and the whole thing falls over. Here's the math I actually use, and why the defaults are quietly wrong for most Laravel deployments.
What pm.max_children Actually Controls
PHP-FPM runs your Laravel app in worker processes. Each incoming request grabs a worker, runs through your middleware stack, hits the database, renders a response, and releases the worker back to the pool. pm.max_children is the ceiling on how many workers can exist simultaneously.
If every worker is busy and a new request comes in, Nginx queues it (up to listen.backlog, but that's a different post). If the queue fills, the client gets a 502. That's the failure mode. The opposite failure mode — too many workers — is that you swap out your RAM, the kernel starts thrashing, and everything gets slow enough that you'd have been better off with fewer workers.
The number isn't arbitrary. It's constrained by one thing: RAM.
The Formula
Here's what I actually write on a whiteboard when I'm provisioning a new server:
pm.max_children = floor(available_ram / avg_worker_ram)
Simple, but both inputs need to be measured, not guessed.
Available RAM is not total RAM. On a 4 GB VM running a typical Laravel stack, you've already spent roughly:
- ~200 MB: OS and kernel
- ~150 MB: MySQL or Postgres (if co-located — don't do this in production, but small clients do)
- ~80 MB: Nginx
- ~100 MB: Redis
- ~100 MB: miscellaneous (syslog, cron, your deploy tooling, etc.)
That's ~630 MB gone before PHP-FPM touches anything. On a 4 GB box I budget 3.2 GB for FPM. On an 8 GB box I'm a little more conservative because co-located MySQL actually grows.
Average worker RAM is the one people completely make up. The real number is the resident set size (RSS) of a PHP-FPM worker under load, not at idle. Idle workers are cheap. Workers that have bootstrapped Laravel, loaded your service container, hit the ORM, and are mid-request are not.
I measure it like this — run this while the app is under realistic load, or right after a busy period:
ps --no-headers -o rss,comm -u www-data \
| awk '/php-fpm/ {sum += $1; count++} END {printf "avg: %.0f KB (%d workers)\n", sum/count, count}'
On a lean Laravel 10 app with a handful of packages, I typically see 40–70 MB per worker. A heavier app with Spatie packages, complex Eloquent relationships, and maybe a PDF generation library? I've seen 120–180 MB. I worked on an e-commerce app last year that loaded a massive product catalog relationship eagerly and workers sat at 210 MB. Plugging 60 MB into the formula when the reality is 210 MB means your "safe" max_children is actually 3.5x too high.
So on that 4 GB box with 3.2 GB available and 80 MB per worker:
pm.max_children = floor(3200 / 80) = 40
Leave a 10–15% buffer for spikes and measurement error. I'd set this to 35.
The pm Mode Matters Too
You also have to pick between static, dynamic, and ondemand. I default to dynamic for anything with consistent traffic:
pm = dynamic
pm.max_children = 35
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 15
pm.max_requests = 500
pm.start_servers should be roughly (min_spare + max_spare) / 2. This isn't FPM law, it's just the recommendation in the docs and it holds up.
pm.max_requests is the one people skip and regret. PHP has memory leaks — yours, the framework's, a vendor package's. Setting max_requests = 500 means a worker recycles after 500 requests. You'll see this in pm.status if you've got that endpoint exposed (and you should, at least internally). Leaving it at 0 means workers grow unbounded in RSS over time. I've watched a Laravel app's workers balloon from 80 MB to 400 MB over a weekend with max_requests = 0.
For low-traffic apps or anything running on a tiny droplet, ondemand makes sense — workers spin up per request and die after pm.process_idle_timeout. You trade some cold-start latency for not keeping idle workers resident.
Gotchas That Bit Me
OPcache is not included in the worker RSS. OPcache lives in shared memory outside the per-process RSS. On a 4 GB box I'm typically setting opcache.memory_consumption=256 (MB), which means I need to subtract another 256 MB from available RAM before doing the FPM math. Forget this and you'll be confused why your memory numbers don't add up.
Queue workers are not FPM workers. If you're running php artisan queue:work as a systemd service (you should be), those are separate PHP processes consuming RAM outside of FPM's pool. I've seen setups where someone allocated 40 FPM workers and 20 queue workers on a 4 GB box and wondered why it was swapping. Count everything.
Measuring during a cold start is useless. PHP-FPM workers don't load your full application at boot. They do it on the first request (or a few requests, before OPcache warms up). If you measure worker RSS 5 seconds after a restart, you'll measure something like 15 MB. Measure during real traffic.
The status endpoint is your friend. Add this to your Nginx config for a local-only status check:
location /fpm-status {
access_log off;
allow 127.0.0.1;
deny all;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
Then curl -s http://127.0.0.1/fpm-status gives you:
pool: www
pm: dynamic
start time: ...
start since: 86400
accepted conn: 142398
listen queue: 0
max listen queue: 12
listen queue len: 511
idle processes: 8
active processes: 4
total processes: 12
max active processes: 28
max children reached: 3
max children reached is the number I watch. If it's climbing over days, your max_children is too low. If your active processes never exceed 5 but you've got 35 workers sitting idle, you're wasting RAM that MySQL or Redis could use.
listen.backlog defaults to 511 on Linux. This is the queue depth for requests waiting for a free worker. Under a real traffic spike that's not enough, but increasing it without increasing max_children just means clients wait longer before failing instead of failing fast. Know which behavior you want.
When I'd Tune This Way (and When I Wouldn't)
This whole approach is for a single-VM deployment — the kind of setup that's actually common for small-to-mid business clients who don't need (and can't justify) Kubernetes or a load balancer fleet. A healthcare portal doing 5,000 requests a day, a regional e-commerce shop, an internal operations tool for a Seattle manufacturer — these all live on a single Linode or DigitalOcean droplet and the math above applies directly.
If you're behind a load balancer with multiple app servers, the formula still works per-node, but you have more room to be conservative since one saturated node doesn't take down the whole app.
If you're in a containerized environment where each PHP-FPM container has resource limits via Docker or Kubernetes, the math is almost the same — just use the container's memory limit instead of the VM's available RAM, and be more aggressive with your buffer since OOM kills are abrupt.
I wouldn't obsess over this if you're on a box with 32 GB RAM and a lightweight app. The math still matters but you have enough room that being off by 20% doesn't hurt. Where it matters is the 2–8 GB range where small clients actually live, and where getting it wrong causes real outages at the worst times.
The Actual Config I'd Deploy
For a medium Laravel app on a 4 GB VM with co-located Redis (no MySQL — use RDS or a managed database):
; /etc/php/8.2/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 35
pm.start_servers = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 12
pm.max_requests = 500
pm.process_idle_timeout = 10s
php_admin_value[opcache.memory_consumption] = 256
php_admin_value[opcache.interned_strings_buffer] = 16
php_admin_value[opcache.max_accelerated_files] = 10000
php_admin_value[opcache.revalidate_freq] = 0
php_admin_value[opcache.validate_timestamps] = 0
Then I watch max children reached and actual worker RSS for a week after launch and adjust. It's not a one-time calculation.
The reason nobody writes this math down is that it requires measuring your specific app, and "just set it to 50" is easier to put in a blog post. But 50 is wrong for most of the apps I work on. Measure the RSS, account for everything else in RAM, leave a buffer, and watch the status endpoint. Takes 20 minutes and saves you a midnight 502 incident.
Need help shipping something like this? Get in touch.