Tutorial

PHP-FPM Tuning: Get 3x More Performance from Your Server

Back to Blog
Managing servers the hard way? Panelica gives you isolated hosting, built-in Docker and AI-assisted management.
Start free

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:

  1. Baseline: stock PHP-FPM config, no OPcache
  2. Enable OPcache: record improvement
  3. Tune pm.max_children: record improvement
  4. Switch pm mode: record improvement
  5. 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_children values
  • Security isolationopen_basedir set 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:

  1. Enable OPcache with validate_timestamps=0 — 50-200% throughput improvement, immediate
  2. Calculate the right pm.max_children — stop 502 errors from worker starvation
  3. Choose the right pm modestatic for high traffic, dynamic for mixed, ondemand for shared hosting
  4. Enable the slow log — find your actual bottlenecks instead of guessing
  5. 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.

Security-first hosting panel

Hosting management, the modern way.

Panelica is a modern, security-first hosting panel — isolated services, built-in Docker and AI-assisted management, with one-click migration from any panel.

Zero-downtime migration Fully isolated services Cancel anytime
Share:
Are your backups really safe?