24 workers configured, 6 ever used, and the number that actually mattered was MySQL's max_connections

PHP-FPM worker count is not a performance dial you turn up. It is the smallest of three separate limits — memory, database connections, and CPU — and on most Laravel deployments the database decides it long before memory does. Raising pm.max_children past that point does not make the application faster; it converts a slow application into a broken one.
This is what we learned taking a ride-hailing API off PHP's development server, and the number we ended up with was smaller than our first instinct by an order of magnitude.
The failure that started it
The API was running under php artisan serve. Not by decision — it had been that way since the project was scaffolded, and nothing had ever pushed hard enough to expose it.
A load test did. At roughly 15 requests per second of mixed traffic the service stopped responding, and here is the part that mattered: a ten-minute traffic spike produced a thirty-five-minute outage. Arrivals stopped and the service stayed down. It recovered only when we restarted the container.
That asymmetry is the whole lesson. A system that degrades gracefully returns when load returns to normal. A system that queues without bound does not — it keeps working through a backlog that no longer has anyone waiting on it.
Why one process is a hard ceiling
artisan serve wraps PHP's built-in development server. It is single-process and handles one request at a time. Request two waits for request one, whatever it is doing.
PHP-FPM's model is different in the way that matters: it maintains a pool of worker processes, and each concurrent request occupies exactly one worker for its entire lifetime. Not its CPU time — its lifetime. A worker blocked for 300ms waiting on a database query is unavailable for those 300ms even though it is consuming almost no CPU.
So your concurrency ceiling is the worker count, and the obvious move is to make the worker count large. That is where people get hurt.
The three limits that decide max_children
Limit 1: memory
Every worker is a real OS process with its own memory. The arithmetic is unforgiving:
max_children ≤ (RAM available to PHP) / (average worker RSS)Measure the average rather than guessing it. A Laravel worker serving a JSON API commonly sits between 40 MB and 120 MB depending on how much of the framework each route touches. Take the figure under real traffic, not at boot — a freshly forked worker is always smaller than one that has served a hundred requests.
The trap here is that exceeding this limit does not produce a clean error. It produces the OOM killer choosing a victim, and the victim is chosen by memory footprint, not by fault. On a shared box the process that dies is frequently your database, not the PHP pool that caused it.
Limit 2: database connections — the one people miss
This is the limit that actually bound us, and it is invisible until it isn't.
Each worker handling a request generally holds its own database connection. MySQL's default max_connections is 151. If you set pm.max_children = 200 because the box has the RAM for it, then at the exact moment your traffic justifies 200 workers, roughly fifty of them receive a connection error instead of a database handle.
max_children ≤ max_connections − headroomHeadroom is not optional and it is larger than it looks. Reserve connections for the queue worker, the scheduler, any Kafka or event consumer, migrations during a deploy, your monitoring exporter, and a human being with a database client open during an incident. That last one has ended more incidents badly than it should have.
The failure mode is worth dwelling on. Under-sizing workers gives you a slow site. Over-sizing them past the connection cap gives you a site that returns 500s specifically when it is busiest, which is both the worst time and the hardest to reproduce afterwards.
Limit 3: CPU, weighted by what your requests actually do
For CPU-bound work, more workers than cores buys nothing — it adds context switching to the same finite compute. For I/O-bound work, where workers spend most of their lifetime waiting on a database or an upstream HTTP call, worker count can exceed core count substantially, because the waiting overlaps.
Most Laravel API requests are I/O-bound, which is why a modest core count still supports a healthy pool. But the ratio is a property of your routes, not a constant. Measure it before borrowing anyone's rule of thumb, including this one.
What we set, and what we measured
The pool, on an eight-core host shared with the database, cache, message broker and several Node services:
pm = dynamic
pm.max_children = 24
pm.start_servers = 8
pm.min_spare_servers = 6
pm.max_spare_servers = 12
pm.max_requests = 1000
pm.status_path = /fpm-statusTwenty-four, on a box that could hold far more by the memory arithmetic alone. The database cap and the shared-tenancy reality set it, not RAM.
Then we measured. Under a full booking workload — WebSocket connections, dispatch, offer handling, ride completion — the pool peaked at six of twenty-four workers, with a listen queue of zero and max children reached at zero.
Six of twenty-four is not a sign the setting is wrong. It means the ceiling is currently generous, which is exactly what you want a ceiling to be. The number that would tell us to raise it is max children reached, and it has never left zero.
pm.max_requests = 1000 deserves a note: each worker retires after a thousand requests and is replaced. That bounds the damage from any slow leak in your code or an extension, at the cost of an occasional process fork. On a long-lived pool it is close to free insurance.
Three traps that cost us time
opcache looks disabled when you check it from the command line
We ran a quick command-line check and got zeros back:
php -r 'var_dump(opcache_get_status(false));'
# Warning: Trying to access array offset on falseThat output is correct and means nothing. The CLI SAPI has opcache.enable_cli off by default, so a command-line probe reports on a completely different configuration from the one serving your web traffic. Check through the FPM binary instead:
php-fpm -i | grep -E 'opcache.enable|opcache.memory'
# opcache.enable => On => On
# opcache.memory_consumption => 256 => 256Ten minutes disappeared into diagnosing a problem that did not exist. Verify through the same SAPI that serves the traffic, always.
A Docker memory cap can grant more memory than you think
Setting --memory=5g without --memory-swap does not confine a container to 5 GB. Docker grants that much RAM plus the same again in swap. A cap set above the box's available RAM therefore licenses the container to exhaust memory and then thrash, which is slower and harder to diagnose than a clean kill.
# Bounded: the container is OOM-killed alone, the host survives
docker run --memory=3g --memory-swap=3g ...An uncached route table taxes every request equally
Worth checking before you touch the pool at all. Our route file compiles to roughly 1.5 MB. Without route:cache, that table is rebuilt on every single request — a flat cost of several hundred milliseconds on every endpoint, which no amount of worker tuning removes.
php artisan route:cacheA worker held for 300ms of avoidable work is a worker you do not have. Fixing this is often worth more than doubling the pool, and it is one command.
How to tell whether workers are your problem at all
Enable the status endpoint and read two fields:
curl -s localhost/fpm-status | grep -E 'active processes|listen queue|max children reached'max children reachedclimbing — the pool is genuinely the ceiling. Raise it, within the three limits above.listen queueabove zero whilemax children reachedstays at zero — requests are waiting, but not for workers. Look downstream: slow queries, an uncached route table, a blocking upstream call.- Both at zero under load — the web tier is not your bottleneck. Measure elsewhere before changing anything here.
That second case is the common one, and it is where worker tuning becomes cargo cult. If your workers are idle-but-occupied, you do not have a concurrency problem; you have a latency problem wearing a concurrency costume.
The principle
Every capacity fix relocates the bottleneck rather than removing it. Moving off the development server did not make the system fast — it made the next constraint visible, which turned out to be the database sitting behind those workers.
So size the pool from the limits you can measure, set it to something defensible, and then watch the counter that tells you it was wrong. A number you can justify and monitor beats a larger number you picked because the box looked like it could take it.
Frequently Asked Questions
Why does my Laravel site still queue requests when max children reached is zero?
How many PHP-FPM workers can I run before MySQL runs out of connections?
Why does opcache_get_status return nothing when I check it with php -r?
Is php artisan serve safe to use in production?
Should pm be dynamic, static or ondemand for a Laravel API?
Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.
Was this post helpful?



