Skip to content
ArticleLaravel

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

D

Dinesh Wijethunga

August 19, 2026Reviewed Aug 18, 2026 8 min readIntermediate
ShareX / TwitterLinkedIn
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 − headroom

Headroom 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-status

Twenty-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 false

That 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 => 256

Ten 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:cache

A 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 reached climbing — the pool is genuinely the ceiling. Raise it, within the three limits above.
  • listen queue above zero while max children reached stays 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?
Because the workers are idle waiting on something else, usually the database or an uncached route table. A worker occupied for 300ms of query time is unavailable even though it is not busy in PHP. Fix the downstream latency; adding workers only adds waiting.
How many PHP-FPM workers can I run before MySQL runs out of connections?
Each busy worker typically holds one connection, so your ceiling is max_connections minus headroom for migrations, the queue worker, the scheduler and your own admin sessions. With MySQL's default of 151, running 200 workers guarantees connection errors under the exact load you sized for.
Why does opcache_get_status return nothing when I check it with php -r?
The CLI SAPI has opcache disabled by default, so a command-line check reports zeros even when opcache is enabled and working for web requests. Check through PHP-FPM instead, with php-fpm -i or a status endpoint.
Is php artisan serve safe to use in production?
No. It is a single-process development server that handles one request at a time and queues the rest without bound. A ten-minute traffic spike can outlast the spike itself by hours, because the queue keeps draining long after arrivals stop.
Should pm be dynamic, static or ondemand for a Laravel API?
Use dynamic when the box is shared with other services, because idle workers get reclaimed. Use static when PHP-FPM owns the machine and you want no fork latency under burst. Avoid ondemand for latency-sensitive APIs, since the first request after idle pays process startup.
D
Dinesh Wijethunga

Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.

Was this post helpful?

Add a comment

Comments

Guest comments are held for moderation.

You might also like