PHP Powers the Web — But Default Settings Leave Performance on the Table
PHP runs 77% of the websites on the internet. WordPress, Laravel, Symfony, Magento, Drupal — the stack that keeps the web alive is almost always PHP. Yet most servers are running default PHP-FPM configurations that were never designed for production workloads. They were designed to work, not to perform.
PHP-FPM (FastCGI Process Manager) is the bridge between your web server (Nginx, Apache) and PHP. It manages a pool of worker processes that execute PHP code. Get it right and your server handles traffic spikes without breaking a sweat. Get it wrong and you're looking at 502 errors, memory exhaustion, and users bouncing.
This guide is scoped to the FPM process manager specifically: how it actually works, the formula for calculating pm.max_children, choosing between static/dynamic/ondemand, slow log analysis, and production monitoring. For a deeper dive into OPcache internals and php.ini-level tuning, see PHP Performance Tuning — this guide covers OPcache and php.ini only briefly, as context for the FPM settings that depend on them.
The goal: 3x more throughput from the same hardware, zero additional cost.
1. How PHP-FPM Actually Works
Before tuning anything, you need to understand the architecture. PHP-FPM runs as a daemon with two types of processes:
- Master process — Reads the configuration, manages worker lifecycle, listens on the socket
- Worker processes (children) — Each worker handles exactly ONE PHP request at a time, then becomes available for the next
The request flow looks like this:
Browser → Nginx → Unix Socket / TCP Port → FPM Master → Available Worker → Execute PHP → Response → Nginx → Browser
The critical insight: if all workers are busy, new requests queue up. When the queue fills, requests get dropped — and you see 502 Bad Gateway errors. This is the single most common performance problem on PHP servers, and it's entirely preventable with proper configuration.
Unix Socket vs TCP
PHP-FPM can listen on either a Unix socket (a file on disk) or a TCP port. For Nginx and PHP-FPM on the same server, always use Unix sockets:
; Unix socket (preferred — same server)
listen = /run/php/php8.4-fpm.sock
; TCP (only for remote PHP-FPM servers)
listen = 127.0.0.1:9000
Unix sockets skip the entire TCP/IP stack — no port binding, no loopback, no packet overhead. Benchmarks consistently show Unix sockets delivering 10-15% better throughput for local PHP-FPM connections.
2. Process Manager Modes — The Most Important Setting
The pm directive controls how PHP-FPM manages worker processes. This is the single biggest lever you have for performance vs RAM usage. There are three modes:
static — Fixed Workers, Always Running
pm = static
pm.max_children = 50
static starts exactly pm.max_children workers at startup and keeps them running forever, regardless of load. No spawning overhead, no startup delay.
- Pros: Fastest response time, completely predictable memory usage, no spawn overhead during traffic spikes
- Cons: Consumes the same RAM at 3 AM as at 3 PM peak traffic
- Best for: Dedicated single-site servers, high-traffic production sites with consistent load
dynamic — The Default (and Usually Wrong)
pm = dynamic
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 35
dynamic spawns and kills workers based on demand, keeping between min_spare_servers and max_spare_servers idle workers ready.
- Pros: Balanced RAM usage, good for variable traffic
- Cons: Spawning workers takes ~50-100ms each — during sudden traffic spikes this causes latency
- Best for: General-purpose servers, mixed workloads, most production environments
A common mistake with dynamic mode: setting pm.start_servers and pm.min_spare_servers too low. If these are set to 2-3 on a high-traffic site, PHP-FPM is constantly spawning and killing workers. Set pm.min_spare_servers to at least 20-30% of pm.max_children.
ondemand — Minimal Footprint
pm = ondemand
pm.max_children = 50
pm.process_idle_timeout = 10s
ondemand starts with zero workers. Workers spawn when a request arrives and die after being idle for process_idle_timeout seconds.
- Pros: Minimal RAM when idle — perfect for shared hosting with 50+ pools
- Cons: Cold start latency on first request, not suitable for sites with consistent traffic
- Best for: Shared hosting with many idle sites, low-traffic pools, development environments
Decision Table
| PM Mode | RAM Usage | Response Time | Traffic Spikes | Best For |
|---|---|---|---|---|
static |
High (constant) | Fastest | Excellent | Dedicated, high traffic |
dynamic |
Medium (varies) | Good | Good | Most servers |
ondemand |
Low (when idle) | Slower first req | Poor | Shared hosting, many pools |
3. Calculating pm.max_children — The Formula
This is where most tuning guides fail you — they give you a number without explaining where it comes from. The formula is simple:
pm.max_children = (Available RAM for PHP) / (Average PHP Worker Size)
First, find your average PHP worker memory usage:
ps -eo rss,comm | grep php-fpm | awk '{sum+=$1; count++} END {print sum/count/1024 " MB average per worker"}'
For a server without existing PHP-FPM data, use these typical values as starting points:
- WordPress site: 40-60 MB per worker
- Laravel application: 50-80 MB per worker
- Magento / WooCommerce: 80-128 MB per worker
- Simple PHP scripts: 20-30 MB per worker
Now calculate based on your server RAM. Always leave RAM for the OS, Nginx, database, and other services — usually 40-50% of total RAM is available for PHP:
| Server RAM | PHP Budget | 40 MB/worker | 64 MB/worker | 128 MB/worker |
|---|---|---|---|---|
| 2 GB | ~800 MB | 20 | 12 | 6 |
| 4 GB | ~2 GB | 50 | 32 | 16 |
| 8 GB | ~4 GB | 100 | 64 | 32 |
| 16 GB | ~8 GB | 200 | 128 | 64 |
| 32 GB | ~16 GB | 400 | 256 | 128 |
Warning Signs
Signs you have too few workers:
- 502 Bad Gateway errors under load
- FPM status shows listen queue > 0
- All workers show "Running" status with no idle workers
- Slow responses even with fast PHP code
Signs you have too many workers:
- Server starts swapping (disk I/O spikes, everything slows down)
- OOM killer activating (check
dmesg | grep -i "killed process") - Free memory drops below 10% even at idle
- Server becomes unresponsive under load
4. OPcache — Why It Matters for FPM Sizing
OPcache is not an FPM setting, but it directly affects how many FPM workers you need: without it, every worker spends part of each request re-compiling PHP files from scratch, which increases per-request time and therefore increases the number of concurrent workers required to hold the same throughput. The short version: enable it, set opcache.validate_timestamps=0 in production, and give it enough opcache.memory_consumption that opcache_get_status() shows a hit rate above 99%. For the full configuration reference — memory sizing, JIT modes, cache invalidation on deploy — see PHP Performance Tuning.
5. Pool Configuration — Full Production Example
Here's a complete, commented pool configuration for a production web application:
[site_production]
; Run as the site's dedicated user for security isolation
user = www-data
group = www-data
; Unix socket — faster than TCP for local connections
listen = /run/php/php8.4-fpm-production.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
; Process management — change based on your server profile
pm = dynamic
pm.max_children = 50
pm.start_servers = 10 ; Start 20% of max ready
pm.min_spare_servers = 10 ; Never drop below 20%
pm.max_spare_servers = 35 ; Kill idle workers above this
pm.max_requests = 500 ; Restart worker after 500 requests (memory leak prevention)
; Monitoring
pm.status_path = /fpm-status
pm.status_listen = /run/php/php8.4-fpm-status.sock ; Dedicated status socket
; Timeouts
request_terminate_timeout = 30s ; Kill workers taking longer than 30s
request_slowlog_timeout = 5s ; Log requests taking longer than 5s
; Slow log
slowlog = /var/log/php-fpm/production-slow.log
; Environment
clear_env = no ; Pass environment variables to workers
env[HOSTNAME] = $HOSTNAME
; Security — per-site php.ini overrides
php_admin_value[open_basedir] = /var/www/production:/tmp:/usr/share/php
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
php_admin_flag[expose_php] = off
php_admin_value[error_log] = /var/log/php-fpm/production-error.log
php_admin_flag[log_errors] = on
6. pm.max_requests — Memory Leak Prevention
PHP extensions can leak memory over long-running processes. Not much — maybe a few KB per request — but over thousands of requests it adds up. The pm.max_requests setting tells PHP-FPM to gracefully restart a worker after handling N requests, starting fresh.
pm.max_requests = 500 ; Good baseline — restart after 500 requests
Finding the right value:
- 500: Good starting point for most applications
- 1000: Fine for applications with no memory issues
- 0: Never restart — only safe if you're certain there are no memory leaks
- 100-200: If you're seeing gradual memory growth, try this
To monitor worker memory growth:
watch -n 2 'ps -eo rss,pid,comm | grep php-fpm | sort -rn | head -20'
If workers are growing from 40MB to 150MB+ over hours, lower pm.max_requests. If memory stays stable, you can safely increase it.
7. Slow Log — Find Your Real Bottlenecks
The slow log is one of the most useful and underused PHP-FPM features. When a request takes longer than request_slowlog_timeout, PHP-FPM captures a full stack trace and writes it to the slow log.
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/slow.log
A slow log entry looks like this:
[28-Mar-2026 14:23:11] [pool production] pid 12847
script_filename = /var/www/site/wp-cron.php
[0x00007f2a3c001b00] pdo_query() /var/www/site/wp-includes/class-wpdb.php:1789
[0x00007f2a3c001a10] get_option() /var/www/site/wp-includes/option.php:124
[0x00007f2a3c001920] wp_check_post_hierarchy_for_loops() /var/www/site/wp-cron.php:312
This tells you exactly which function is slow, in which file, on which line. Analyze the most frequent offenders:
# Find the most common slow functions
grep "script_filename" /var/log/php-fpm/slow.log | sort | uniq -c | sort -rn | head -20
# Find slow scripts
grep "^script_filename" /var/log/php-fpm/slow.log | awk '{print $3}' | sort | uniq -c | sort -rn | head -10
Common findings and their causes:
- Database functions: Missing indexes, N+1 queries, no query cache
- External API calls: Third-party services timing out — consider async processing
- File operations: Reading large files on every request — consider caching
- wp-cron.php: WordPress running cron jobs on web requests — offload to real cron
8. FPM Status Page — Real-Time Monitoring
PHP-FPM has a built-in status page that gives you a real-time view of pool health:
; In pool.conf
pm.status_path = /fpm-status
Expose it securely in Nginx (only accessible from localhost):
location /fpm-status {
allow 127.0.0.1;
deny all;
access_log off;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# JSON format: /fpm-status?json
# Full per-process details: /fpm-status?full
# JSON full: /fpm-status?json&full
Key metrics to watch:
| Metric | What It Means | Alert Threshold |
|---|---|---|
| listen queue | Requests waiting for a worker | Alert if > 0 sustained |
| active processes | Workers currently handling requests | Alert if > 90% of max_children |
| max children reached | Times max_children was hit (counter) | Alert if increasing |
| slow requests | Requests that triggered slow log | Alert if > 1% of total |
| accepted conn | Total requests handled (lifetime) | Rate metric — normal is good |
9. php.ini Settings That Affect FPM Worker Behavior
A handful of php.ini settings interact directly with FPM worker sizing. memory_limit caps how much RAM a single script can use before it is killed — set too high, one runaway script can starve a worker's slot for a long time; set too low, legitimate imports and exports fail. max_execution_time and request_terminate_timeout (the FPM-side equivalent) should agree with each other, or FPM will kill a worker before PHP even gets a chance to. For the complete php.ini reference — session handling, upload limits, output buffering — see PHP Performance Tuning.
10. Nginx + PHP-FPM Integration Tuning
The Nginx side of the equation matters too. FastCGI buffering settings directly affect how PHP-FPM workers are utilized:
upstream php_fpm {
server unix:/run/php/php8.4-fpm.sock;
keepalive 32; # Persistent connections to FPM
}
server {
# ... server config ...
location ~ \.php$ {
fastcgi_pass php_fpm;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# Buffering — keeps PHP workers from waiting on slow clients
fastcgi_buffering on;
fastcgi_buffer_size 32k;
fastcgi_buffers 8 32k;
fastcgi_busy_buffers_size 64k;
# Timeouts
fastcgi_connect_timeout 5s;
fastcgi_send_timeout 30s;
fastcgi_read_timeout 30s;
# Don't send FPM status to clients
fastcgi_hide_header X-Powered-By;
# Intercept PHP errors
fastcgi_intercept_errors on;
}
}
Why fastcgi_buffering Matters
Without fastcgi_buffering on, Nginx waits for the client to receive each byte before reading more from PHP-FPM. On a slow client connection, the PHP-FPM worker is stuck for the entire transfer duration. With buffering enabled, Nginx reads the full PHP response into memory, releases the PHP-FPM worker, then sends the response to the (slow) client at its own pace. This can double or triple the effective throughput on servers with many slow clients.
11. Multi-PHP Version Pools — Per-Site PHP Version
Modern hosting often requires multiple PHP versions simultaneously — legacy applications on PHP 8.1, newer projects on PHP 8.4. The solution is separate PHP-FPM instances per version, each with their own socket:
# PHP 8.1 pool
listen = /run/php/php8.1-fpm.sock
# PHP 8.4 pool
listen = /run/php/php8.4-fpm.sock
In Nginx, point each site to its required version:
# Site 1 — Legacy app on PHP 8.1
server {
server_name legacy.example.com;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.1-fpm.sock;
}
}
# Site 2 — Modern app on PHP 8.4
server {
server_name modern.example.com;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
}
This is what Panelica manages automatically — per-user, per-version PHP-FPM pools with full process isolation between users. Each pool runs under the site owner's system user, with open_basedir set to their web root, so one site literally cannot read files from another.
12. Monitoring PHP-FPM in Production
The FPM status page is good for real-time inspection. For production alerting, integrate with Prometheus using php-fpm_exporter:
# Docker
docker run -p 9253:9253 \
-e PHP_FPM_SCRAPE_URI="tcp://localhost:9000/fpm-status" \
hipages/php-fpm_exporter
Key Prometheus metrics to alert on:
| Metric | Alert Condition |
|---|---|
phpfpm_active_processes |
> 85% of phpfpm_max_children |
phpfpm_listen_queue |
> 0 for more than 30 seconds |
phpfpm_max_children_reached_total (rate) |
Rate > 0 sustained |
phpfpm_slow_requests_total (rate) |
Rate > 0.01 (1 slow req per 100) |
Set up Grafana dashboards with these metrics and you'll know about PHP-FPM saturation before users start complaining.
13. Benchmarking — Measure Before and After
Never tune by feel. Establish a baseline, change one variable, measure again. Repeat.
# Apache Bench — simple throughput test
ab -n 10000 -c 100 -k https://yoursite.com/
# -n: total requests, -c: concurrent users, -k: keepalive
# wrk — more realistic load testing
wrk -t 4 -c 100 -d 30s --latency https://yoursite.com/
# -t: threads, -c: connections, -d: duration
# wrk2 — constant-rate testing (remove coordinated omission)
wrk2 -t 4 -c 100 -d 30s -R 500 --latency https://yoursite.com/
# -R: target requests per second
What to record for each test:
- Requests/second — throughput
- Latency p50/p95/p99 — median and tail latency
- Error rate — 4xx/5xx percentage
- Server RAM during test — watch for OOM conditions
Example test sequence:
- Baseline: stock PHP-FPM config, no OPcache
- Enable OPcache: record improvement
- Tune pm.max_children: record improvement
- Switch pm mode: record improvement
- Enable JIT: record improvement (or lack thereof)
14. Common Problems and Solutions
502 Bad Gateway
Cause: PHP-FPM is not running, socket doesn't exist, or all workers are busy and the listen queue overflowed.
# Check if FPM is running
systemctl status php8.4-fpm
# Check socket exists
ls -la /run/php/php8.4-fpm.sock
# Check error log
tail -50 /var/log/php-fpm/error.log
# Check for listen queue overflow in status
curl -s "http://localhost/fpm-status?json" | python3 -m json.tool | grep queue
504 Gateway Timeout
Cause: PHP script is taking longer than the fastcgi_read_timeout in Nginx or request_terminate_timeout in FPM.
# Check which requests are slow
tail -100 /var/log/php-fpm/slow.log
# Increase timeout for legitimate long-running scripts (imports, exports)
# In pool.conf:
request_terminate_timeout = 300s
# In Nginx:
fastcgi_read_timeout 300s;
Allowed Memory Size Exhausted
Cause: Script exceeded memory_limit.
# Increase for the whole pool (php.ini or pool config)
php_admin_value[memory_limit] = 512M
# Or increase for a specific script via .htaccess (Apache) or Nginx
# fastcgi_param PHP_VALUE "memory_limit=512M";
max children reached (counter keeps increasing)
Cause: Traffic exceeds worker capacity. You need either more workers (more RAM) or faster PHP execution (better code, more caching).
# Check current max children reached counter
curl -s "http://localhost/fpm-status?json" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print('Max children reached:', d['max children reached'])
print('Active processes:', d['active processes'])
print('Idle processes:', d['idle processes'])
"
Server Slows Down Over Time (Memory Leak)
Cause: Worker memory growing over time due to PHP extension leaks. Solution: lower pm.max_requests.
# Monitor worker memory growth
while true; do
ps -eo rss,pid,comm | grep "php-fpm" | sort -rn | head -5
sleep 10
done
# Set in pool.conf
pm.max_requests = 200 # More aggressive restart
Frequently Asked Questions
Should I use static or dynamic pm mode?
Use static for a dedicated, consistently high-traffic site where you want the fastest possible response time and do not mind constant RAM usage. Use dynamic for general-purpose servers with variable traffic. Use ondemand only for shared hosting with many mostly-idle pools, where RAM at rest matters more than cold-start latency.
How do I know if pm.max_children is set too low?
Check the FPM status page for a non-zero listen queue, or watch for 502 Bad Gateway errors under load. Both indicate requests are arriving faster than workers can free up — the fix is either more workers (if RAM allows) or faster PHP execution.
What does pm.max_requests actually protect against?
Gradual memory leaks in PHP extensions that accumulate over the life of a long-running worker process. Restarting a worker after N requests (500 is a reasonable default) resets its memory footprint before a slow leak becomes a real problem.
Is a Unix socket really faster than TCP for local PHP-FPM connections?
Yes, measurably — typically 10-15% better throughput, because a Unix socket skips the TCP/IP stack entirely (no port binding, no loopback routing). Always use a Unix socket when Nginx and PHP-FPM run on the same server.
Complete Production Configuration
Here's a complete, ready-to-use configuration for a 4GB VPS running WordPress or Laravel. Adjust pm.max_children based on the formula above.
pool.conf
[production]
user = www-data
group = www-data
listen = /run/php/php8.4-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
listen.backlog = 511
pm = dynamic
pm.max_children = 40
pm.start_servers = 8
pm.min_spare_servers = 8
pm.max_spare_servers = 28
pm.max_requests = 500
pm.status_path = /fpm-status
request_terminate_timeout = 30s
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/slow.log
rlimit_files = 65535
rlimit_core = unlimited
php_admin_value[open_basedir] = /var/www:/tmp:/usr/share/php
php_admin_flag[expose_php] = off
php_admin_value[error_log] = /var/log/php-fpm/php-errors.log
php_admin_flag[log_errors] = on
php_admin_flag[display_errors] = off
opcache.ini
[opcache]
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.fast_shutdown=1
opcache.jit_buffer_size=128M
opcache.jit=1255
php.ini performance section
memory_limit = 256M
max_execution_time = 30
max_input_time = 60
upload_max_filesize = 64M
post_max_size = 64M
output_buffering = 4096
realpath_cache_size = 4096k
realpath_cache_ttl = 600
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379"
display_errors = Off
log_errors = On
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
Quick Reference
| Setting | Recommended Value | Why |
|---|---|---|
pm |
dynamic (general) / static (high traffic) |
Balance RAM vs performance |
pm.max_children |
(Available RAM) / (Worker size) | Core capacity formula |
pm.max_requests |
500 | Prevent memory leaks |
opcache.validate_timestamps |
0 (production) | Eliminate stat() syscalls |
opcache.memory_consumption |
256 MB | Cache enough scripts |
opcache.jit |
1255 | Tracing JIT for web apps |
listen |
Unix socket | Faster than TCP loopback |
fastcgi_buffering |
on | Release workers from slow clients |
realpath_cache_size |
4096k | Reduce filesystem calls |
How Panelica Handles PHP-FPM Automatically
Everything in this guide — pool creation, process manager tuning, OPcache configuration, per-user isolation — is something you'd configure manually on a traditional server. Panelica automates all of it.
When you create a user or enable a PHP version for a site, Panelica generates a dedicated PHP-FPM pool running under that user's system account with:
- Per-user pools — Separate FPM pool per user per PHP version. One user's misconfigured WordPress can't saturate workers for other sites.
- Automatic pm tuning — Based on the user's cgroup memory limits, Panelica calculates appropriate
pm.max_childrenvalues - Security isolation —
open_basedirset to the user's home directory, preventing cross-site file access - OPcache per pool — Each pool gets its own OPcache namespace, preventing bytecode interference between sites
- PHP version switching — Change a site's PHP version from the panel — new pool created, old one gracefully drained, zero downtime
If you're managing more than a handful of PHP sites, the manual approach described in this guide is educational but tedious at scale. Panelica's free Starter plan (3 domains, with 14-day premium preview) gives you a full panel install to test on your own server — no credit card required.
Conclusion
PHP-FPM tuning is not black magic. It's straightforward engineering: understand your workload, calculate the right worker count, enable OPcache with production settings, monitor what's happening, and iterate.
The biggest wins, in order of impact:
- Enable OPcache with
validate_timestamps=0— 50-200% throughput improvement, immediate - Calculate the right pm.max_children — stop 502 errors from worker starvation
- Choose the right pm mode —
staticfor high traffic,dynamicfor mixed,ondemandfor shared hosting - Enable the slow log — find your actual bottlenecks instead of guessing
- Switch to Unix sockets — free 10-15% throughput improvement
Do all five and you're looking at 2-3x throughput from the same hardware. That's not a sales pitch — it's arithmetic. Default PHP-FPM is configured to work on any server, not to perform on yours.