"Make PHP faster" is the question. PHP-FPM tuning is one of the answers — but it sits inside a stack where the slowest layer wins, and FPM is almost never the slowest layer on a fresh install. Before you touch a single pm.max_children, the honest question to answer is what is actually waiting for what? A pool sized for 200 workers does nothing if each request blocks 800ms on a MySQL query. A 4 GB OPcache helps no one if validate_timestamps is on and you're stat'ing every file on every request anyway.
This guide covers the settings that genuinely matter — across PHP-FPM's process manager, OPcache and the JIT, realpath cache, the slow log, OS-level limits, and the Nginx ↔ FPM boundary — and, more importantly, how to measure whether changing them helped. Examples assume PHP 8.2+ on a recent distro (Ubuntu 22.04/24.04, AlmaLinux/Rocky 9, Debian 12). Older versions get inline notes where it matters.
Two things to internalize before you change anything:
- Measure first or you are guessing. PHP-FPM ships a status page. Turn it on. Without it, every "tuning" decision is theater — you don't know if your pool is saturated, idle, or thrashing.
- The bottleneck is rarely PHP-FPM itself. Most "slow PHP" problems are slow database queries, slow upstream APIs, missing OPcache, or filesystem stats. Enable the slow log first and look at what your app is actually waiting on before resizing pools.
What PHP-FPM actually does
PHP-FPM is a process manager. A single master process owns one or more pools, and each pool owns a set of worker processes that execute PHP code one request at a time. The web server (Nginx, Apache) sends a FastCGI request over a Unix socket or TCP port; the master hands it to an idle worker; the worker runs your code from start to finish; the worker either dies, is recycled, or returns to the idle pool.
That model has three consequences worth holding in your head:
- Each worker handles one request at a time, blocking from start to finish. If your average request takes 200ms because of a slow query, a worker can serve five requests per second. Your throughput ceiling is
workers × (1/avg_request_time). No FPM setting changes that — only making requests faster does. - Workers are expensive. A warm worker holds a PHP interpreter, every loaded extension, your loaded code, and any per-process caches. 40-80 MB resident per worker is normal; with heavy frameworks 150 MB+ is not unusual. Memory, not CPU, is usually what caps
pm.max_children. - OPcache lives per pool, not per worker. It's shared memory across the workers in a pool, so you pay the memory cost once per pool — not once per worker. That's why OPcache sizing is a pool-level decision.
Knowing this makes the rest of the settings obvious instead of cargo-culted.
Turn on the status page first
You cannot size a pool you cannot see. Edit your pool file (typically /etc/php/8.2/fpm/pool.d/www.conf on Debian/Ubuntu, /etc/php-fpm.d/www.conf on RHEL-family):
pm.status_path = /fpm/status
ping.path = /fpm/ping
Expose it over Nginx, locked to localhost:
location ~ ^/fpm/(status|ping)$ {
allow 127.0.0.1;
deny all;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
Restart PHP-FPM (systemctl restart php8.2-fpm) and curl http://127.0.0.1/fpm/status?full under real load. The metrics you actually care about:
active processesvstotal processes— if active is regularly bumping against total, you're saturated.max active processes— the peak since the pool started. If it equalspm.max_children, you've hit the ceiling at least once and likely shed traffic.listen queue— requests waiting for a worker. Anything other than zero under steady load means workers can't keep up.max listen queue— peak queue depth. Persistent non-zero values are the cleanest signal you need more workers (or faster requests).slow requests— requests that exceeded your slow log threshold. Useful once you've enabled the slow log (covered below).
Without these numbers, the rest of this guide is guesswork. With them, every change becomes a hypothesis you can verify.
Process manager: pm and how to size it
The single most-tweaked block in www.conf is the process manager. There are three modes:
pm = dynamic— keep a configurable number of idle workers ready, scale up to a cap. Default and right for almost everyone.pm = static— always run exactlypm.max_childrenworkers. Right for predictable sustained load (a busy API behind a load balancer) where spinning workers up under burst would hurt latency.pm = ondemand— spawn workers on demand, idle them out after a timeout. Right for low-traffic shared hosts where memory is the constraint and cold-request latency is acceptable.
Picking the mode is the easy part. Sizing pm.max_children is the part that actually matters, and the honest formula is:
pm.max_children = floor(available_memory_for_php / avg_worker_memory)
Both numbers are observable. To find avg_worker_memory, look at RSS for FPM workers under realistic load:
ps -ylC php-fpm8.2 --sort:rss | awk 'NR>1 {sum+=$8; n++} END {if (n>0) print sum/n/1024 " MB"}'
available_memory_for_php is your total RAM minus everything else that needs to run on the box — kernel, OS, web server, database if it's local, monitoring agents, plus a safety margin. On a 4 GB box with MySQL local, budget 1.5-2 GB for PHP. On a 4 GB box where PHP-FPM is the only meaningful tenant, 2.5-3 GB is fair.
So: a 4 GB app server with no local DB and 60 MB workers gives you a ceiling around 3000 / 60 ≈ 50 workers. That number is the cap, not the goal. Set it, then let the status page tell you if you're actually hitting it. If max active processes stays comfortably below it, the cap isn't your bottleneck and raising it changes nothing.
For pm = dynamic, the supporting knobs are:
pm.start_servers = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 16
Sensible defaults: start_servers around 25% of max_children, min_spare_servers around 10-15%, max_spare_servers around 30-40%. The exact numbers matter less than the principle — enough idle workers to absorb a burst without spawning, not so many that you're paying for capacity you don't use.
pm.max_requests — recycle workers to bound memory leaks
pm.max_requests = 500
After this many requests, a worker exits and is replaced. The point is to bound damage from leaky extensions or third-party libraries that slowly accumulate memory. It is not a substitute for fixing real leaks. Too low (50) costs you OPcache locality and spawn overhead; too high (10000) lets a slow leak fill memory. 500-2000 is the right band for most apps. If you have no leaks and want to verify, set it to 0 (never recycle) and watch RSS over a few days.
Listen backlog
listen.backlog = 511
The kernel's pending-connection queue depth for the FPM socket — requests the kernel will hold for FPM to accept(). The default (511 on most builds) is fine until you see listen queue filling under burst, at which point raising it to 1024 or 4096 buys you headroom. It does not increase worker capacity — it just lets the kernel buffer more requests before refusing connections. Pair it with net.core.somaxconn (covered below) — both must be raised together or the kernel silently clamps the FPM value.
OPcache: the single biggest performance lever
OPcache compiles PHP source to bytecode once and caches it across requests. Off, every request reparses every file. On with sensible settings, a typical app gets 2-5× throughput for free. It's bundled with PHP and enabled by default since 5.5, but the defaults are sized for "any PHP install" — not your app.
Edit /etc/php/8.2/mods-available/opcache.ini:
opcache.enable = 1
opcache.enable_cli = 0
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.save_comments = 1
opcache.jit = tracing
opcache.jit_buffer_size = 100M
The settings that matter, in order:
opcache.memory_consumption— shared memory for the bytecode cache, in MB. The default of 128 is anemic for any real framework. Start at 256, raise ifopcache_get_status()['memory_usage']['free_memory']runs low. The cost is RAM you pay once for the entire pool, not per worker — usually a bargain.opcache.max_accelerated_files— the cache hash table size. It must exceed your file count, or OPcache silently stops caching new files. Count your real files:find /var/www -type f -name "*.php" | wc -l. Round up to one of the prime numbers OPcache uses internally (3907, 7963, 16229, 32531…) and set the value above your count. 20000 is fine for most apps; large Magento or Symfony installs need 32531 or higher.opcache.validate_timestamps— when on, OPcache stats every cached file on every request to check for changes. Off, it serves cached bytecode blindly. In production this should be0. Your deploy process should issueopcache_reset()or restart FPM; the cost ofstat()per file per request is the single biggest performance trap on the default config. In development, leave it on withopcache.revalidate_freq = 2(re-check at most every 2 seconds).opcache.interned_strings_buffer— shared memory for deduplicated strings. 16 is the default; 32-64 helps any framework that loads many class names and metadata. Cheap memory, real wins.opcache.jit+opcache.jit_buffer_size— the PHP 8 JIT.tracingis the right mode for web workloads, allocate 100 MB. The honest truth: the JIT helps CPU-bound PHP (math, parsing) significantly, and web-typical "wait for the database" code only marginally. Turn it on, measure, keep it if your app is CPU-bound, ignore if you're I/O bound. It costs nothing to leave on.
After changes, restart FPM and verify with a small status script or cachetool:
cachetool opcache:status --fcgi=/run/php/php8.2-fpm.sock
Watch the hits/misses ratio (should be >99% in steady state) and cached_scripts vs max_cached_keys (should not be near the cap).
Realpath cache — the forgotten lever
Every include, require, file_exists, or is_file call does a path resolution that hits the filesystem unless it's cached. PHP has a separate per-process realpath cache for this, and the default is small:
realpath_cache_size = 4096K
realpath_cache_ttl = 600
Frameworks like Symfony, Laravel, and Magento touch thousands of files per request. The default 16K is a measurable hit; 4 MB is generous and basically free. TTL 600 (10 minutes) is fine. Verify what your app actually uses by calling realpath_cache_size() and realpath_cache_get() from a request — if the returned size is near the limit, raise it.
This is one of those settings where the default has been wrong for ten years and almost no one changes it.
Slow log: find the actual bottleneck
Most "slow PHP" problems aren't PHP. Turn on the slow log to prove it:
slowlog = /var/log/php-fpm/www-slow.log
request_slowlog_timeout = 5s
Any request taking longer than 5 seconds gets a full PHP stack trace dumped to the log — line by line, including which file and function the worker was sitting in. Read a week of those traces and you will almost always find the answer is "blocking on MySQL," "blocking on an upstream API with no timeout," "regenerating a session," or "doing N+1 queries in a loop." None of those are fixed by FPM tuning.
Lower the threshold to 1s or 2s once the 5s ones are gone. The slow log is the single most useful diagnostic file FPM produces — more useful than the status page.
OS-level ties
FPM doesn't run in a vacuum. A handful of OS limits cap what it can do regardless of www.conf:
-
fs.file-max(sysctl) — system-wide open file ceiling. Default on modern kernels is high (millions); rarely an issue, worth verifying withsysctl fs.file-max. -
net.core.somaxconn(sysctl) — kernel cap on listen queue depth.listen.backlogin FPM is silently clamped to this. Default is 4096 on recent kernels (used to be 128 — check yours). If you raisedlisten.backlog, raise this too. -
LimitNOFILEin the systemd unit — per-process open file limit for the FPM master and its workers. The shipped unit usually sets this to 1024 or 4096. For a busy pool with many concurrent connections (Unix sockets count), bump it:# /etc/systemd/system/php8.2-fpm.service.d/override.conf [Service] LimitNOFILE=65535Then
systemctl daemon-reload && systemctl restart php8.2-fpm. -
rlimit_filesinwww.conf— FPM's own per-worker cap, applied on top of the systemd limit. Set it to the same value asLimitNOFILEto make the limit explicit.
These three are the knobs that, when wrong, produce the "but I raised everything in FPM and it still doesn't help" symptom. Check them.
For more on safe sysctl tuning, see How to fine-tune Linux kernel parameters.
Nginx ↔ FPM boundary
The handshake between the web server and FPM is the last place latency hides.
Unix socket vs TCP
For Nginx and FPM colocated on the same host, prefer Unix sockets. Less overhead, no TCP/IP stack traversal:
; in www.conf
listen = /run/php/php8.2-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
Use TCP only when FPM is on a different host (listen = 0.0.0.0:9000). The "TCP is faster under heavy load" claim shows up in old benchmarks against ancient kernels — on Linux 5.15+ it's not true at any load level you'll hit on one box.
FastCGI buffers
Nginx buffers FPM responses. If your responses regularly exceed the buffer, Nginx writes them to disk — invisible latency:
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_busy_buffers_size 64k;
Tail error.log for a client request body is buffered to a temporary file — that's the symptom. The fix is usually raising buffer sizes, not shrinking response sizes.
request_terminate_timeout
request_terminate_timeout = 60s
The hard kill — FPM SIGTERMs a worker that exceeds it. Set it slightly above your real p99 request time. Too low and you kill legitimate long requests; too high (or 0) and a runaway worker holds a slot forever and starves the pool. Pair it with Nginx's fastcgi_read_timeout set to the same value.
Common mistakes
A short list of changes that look like tuning but aren't:
- Setting
pm.max_childrento a huge number "just to be safe." Each worker is real memory. A 4 GB box withpm.max_children = 500will OOM the moment traffic hits the cap and start killing workers — sometimes including MySQL. - Leaving
opcache.validate_timestamps = 1in production because "we'll figure out deploys later." That's paying for astat()per file per request, forever. Fix the deploy process; turn the flag off. - Bumping
listen.backlogwithout raisingnet.core.somaxconn. The kernel silently clamps the FPM value. Both move together or neither does. - Switching to
pm = static"because it's faster" without checking memory. Static means N workers, always, period. If N × worker_memory > available RAM, you'll OOM at boot or under load. - Tuning FPM when the bottleneck is MySQL or an upstream API. The slow log will tell you. Read it before you touch a single
pm.*setting. - Copying settings from a blog post written for PHP 5.6. Half the advice from 2014 is wrong on PHP 8.2 — JIT didn't exist, OPcache defaults were different, the kernel's
somaxconnwas 128. Read primary docs (php.net) or recent guides only.
Verifying that a change made things better
Every change should be a hypothesis with a before/after measurement. The cheap and honest way:
- Hit the status page and slow log; record the metrics you're trying to move (max active processes, listen queue, slow request count, p95 latency).
- Change one thing — not five at once. Restart FPM.
- Run the same load. Compare. Keep the change if it helped, revert if it didn't, never "leave it in just in case."
- Document the change in a comment in the config file with the date and the reason. Three months from now you'll thank yourself.
For sustained visibility, hook the FPM status page into a monitoring system that records the metrics over time — without history, you can't tell whether yesterday's traffic spike was new behavior or your weekly normal.
Related Resources
- How to monitor PHP-FPM — wiring the FPM status page into Xitoring for continuous metrics on active workers, listen queue depth, and slow requests
- How to fine-tune Linux kernel parameters — sysctl tuning for the OS layer FPM sits on
- PHP-FPM Monitoring on Xitoring
- PHP manual: Runtime Configuration for OPcache
- PHP manual: FPM Configuration