PHP-FPM Process Managers: Let the Numbers Decide
Dynamic, static, ondemand - most people just leave PHP-FPM on whatever the package default is. That default is probably wrong for your server.
Most servers I inherit have PHP-FPM sitting on whatever process manager the distro package dropped in. Usually dynamic, usually with defaults that were pulled from thin air. I've watched a $20/month DigitalOcean droplet spend half its life in swap because someone left pm.max_children = 50 on a box with 2GB of RAM. The fix takes five minutes once you understand what you're actually configuring.
What PHP-FPM's Process Manager Actually Does
PHP-FPM doesn't handle requests itself. It manages a pool of worker processes, and each worker handles one request at a time. The pm directive controls how those workers are created and destroyed. Pick the wrong mode and you either waste RAM keeping idle workers alive, burn CPU constantly forking new ones, or - the worst case - let the pool grow until the box OOMs.
There are three modes:
- static - spawn exactly
pm.max_childrenworkers at startup and keep them forever - dynamic - start with
pm.start_servers, grow towardpm.max_childrenunder load, shrink back when idle - ondemand - start with zero workers, spawn on request, kill after
pm.process_idle_timeout
The right choice depends on your traffic shape and your available RAM. Those are the only two variables that matter.
The Memory Math You Have to Do First
Before touching any config, measure your actual worker footprint. Don't guess.
# Check RSS for running PHP-FPM workers
ps --no-headers -o rss,command -C php-fpm8.2 | \
awk '/worker/ {sum += $1; count++} END {printf "count=%d avg=%dkB total=%dMB\n", count, sum/count, sum/1024}'
On a plain Laravel app doing database reads and some Blade rendering, I typically see 40-60MB per worker. A Laravel app with fat service providers, Telescope enabled, or heavy DTO hydration can hit 80-120MB. If you're running something like Bagisto or a multi-tenant platform, I've seen workers balloon past 150MB.
Let's say your workers average 70MB. Your box has 4GB RAM. You're running Nginx, MySQL, and Redis on the same host - typical for a shared VM client of mine. Budget maybe 2.5GB for PHP-FPM after everything else.
2500 MB / 70 MB per worker = ~35 workers max
That's your ceiling. pm.max_children never goes above that number. Everything else is tuning around traffic shape.
Static: Boring, Predictable, Often Right
If your traffic is relatively consistent through the day - think a B2B SaaS with business-hours usage or an internal tool - static is hard to beat. Workers are always ready, no fork overhead, memory usage is flat and predictable.
; /etc/php/8.2/fpm/pool.d/www.conf
pm = static
pm.max_children = 30
; Keep workers fresh - kills a worker after N requests
; Prevents slow memory leaks from adding up
pm.max_requests = 500
The one thing static buys you that people underestimate: predictability under monitoring. If your memory graph is a flat line, you know immediately when something's leaking. With dynamic mode the graph breathes with traffic and anomalies hide in the noise.
Downside: you're paying the RAM cost all the time, even at 3am when nobody's around. On a dedicated VM that's fine. On a box where memory pressure affects MySQL's buffer pool, it matters.
Dynamic: The Default That Needs Actual Tuning
Dynamic is what most people are running and most people have it misconfigured. The defaults that ship with packages are comically wrong.
Here's a real config I'd use for a Laravel e-commerce app on a 4GB shared VM:
pm = dynamic
pm.max_children = 30
pm.start_servers = 8
pm.min_spare_servers = 5
pm.max_spare_servers = 15
pm.max_requests = 500
; How often to log slow requests (optional but worth it)
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/slow.log
The relationships that matter:
start_serversshould be betweenmin_spare_serversandmax_spare_servers. FPM will warn in the log if it's not.max_spare_serversshould be less thanmax_children. Obvious, but I've seen configs where they're equal, which means FPM never actually scales down.min_spare_serversis your floor. Set it too low and you'll have latency spikes while FPM forks workers to handle sudden bursts.
The biggest gotcha with dynamic: it does not protect you from memory exhaustion. If your app has a memory leak and requests pile up, FPM will keep spawning workers up to max_children. Each worker leaks. Eventually the box swaps and dies. pm.max_requests is your pressure valve - it recycles workers periodically, which caps how much any one worker can leak before getting replaced.
Ondemand: For Low-Traffic Sites and Multi-Tenant Setups
Ondemand is underused and I think it has a specific sweet spot: hosting multiple low-traffic sites on one box.
I run managed hosting for a handful of small business clients. One box might have eight WordPress sites and three small Laravel apps, most of which get a few hundred visitors a day. If I gave each pool a min_spare_servers = 3 under dynamic mode, I'm burning 11 pools x 3 workers x 60MB = nearly 2GB of RAM on workers that are mostly sitting idle.
With ondemand:
pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 10s
pm.max_requests = 200
Workers spin up on the first request to that pool and die 10 seconds after going idle. Total RAM consumed is proportional to actual concurrent traffic, not theoretical maximums. On a quiet Sunday morning, most pools have zero workers running.
The cost is latency on cold starts. The first request to a cold pool waits for a fork, which is usually 50-150ms on a healthy box. For a low-traffic site that's fine. For anything with SLA expectations or a checkout flow, that occasional cold-start lag will make you look bad.
I never use ondemand for a primary production app with real traffic. I use it for staging environments and low-traffic tenant pools.
A Laravel-Side Trick That Pairs with This
PHP-FPM config alone doesn't give you the full picture. In Laravel you can log per-request memory from middleware, which helps you catch pools that need recalibrating before the box gets into trouble.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class LogMemoryUsage
{
public function handle(Request $request, Closure $next): mixed
{
return $next($request);
}
public function terminate(Request $request, $response): void
{
$mb = round(memory_get_peak_usage(true) / 1048576, 2);
// Only log if we're creeping up toward your per-worker budget
if ($mb > 50) {
Log::channel('single')->info('high_memory', [
'route' => $request->route()?->getName(),
'method' => $request->method(),
'peak_mb' => $mb,
]);
}
}
}
Register it as a global middleware and watch the logs for a week. If you're seeing 90MB peaks on routes you expected to cost 40MB, your max_children ceiling needs to drop and you need to find the leak.
When I'd Reach for Each
Static - Single app, consistent traffic, dedicated VM or a box where you want memory to be boring and predictable. B2B tools, internal apps, anything where you're monitoring closely and want clear signals.
Dynamic - Most production Laravel apps on a shared VM. Gets you headroom for traffic spikes without burning RAM 24/7. Requires actual math on max_children and non-default values for the spare-server settings.
Ondemand - Managed hosting with many pools, staging environments, low-traffic marketing sites. Anywhere that idle RAM has a real cost and cold-start latency is acceptable.
One Number Most People Skip
Run this after your app has been under load for a while:
php-fpm8.2 -t && grep -i 'max_children' /var/log/php8.2-fpm.log | tail -20
If you see server reached pm.max_children in that log, you've already been dropping requests or queuing them. That line means real users hit a full pool. Raise the ceiling or reduce per-worker memory. Both are levers.
The other metric worth pulling from FPM's status page:
# In your Nginx config, restricted to localhost or a monitoring IP
location /fpm-status {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
allow 127.0.0.1;
deny all;
}
Hit /fpm-status?full and look at max active processes vs your max_children. If they're ever equal in the history, you've been at the wall.
The Closing Take
This is one of those things that feels like tuning minutiae until a client's checkout page starts timing out at 6pm on a Tuesday because nobody did the memory math. Pick a mode that matches your traffic shape, set max_children based on actual worker size not vibes, and recycle workers with max_requests so leaks don't compound. Five minutes of arithmetic on the right afternoon saves a midnight incident call.
Need help shipping something like this? Get in touch.