📁 Volume II: Laravel Performance Tuning Kit

⚙️ Topic 16: PHP-FPM Deep Dive

The real bottleneck nobody talks about.

"You optimized your Laravel code. You added indexes. You configured Redis.
But your server still crashes under load.
Your PHP-FPM settings are killing you."
⚠️ THE SILENT BOTTLENECK

Most developers never touch PHP-FPM configuration. They keep the defaults (designed for development, not production). When traffic spikes, PHP-FPM either spawns too many processes (memory exhaustion) or too few (request queueing). Getting these settings right is critical for production.

🔍 What is PHP-FPM?

PHP-FPM (FastCGI Process Manager) is the bridge between your web server (Nginx/Apache) and your PHP code. HOW IT WORKS: ═══════════════════════════════════════════════════════════════════ HTTP Request │ ▼ ┌─────────────┐ │ Nginx │ └──────┬──────┘ │ (fastcgi_pass) ▼ ┌─────────────────────────────────────────────────────────────────┐ │ PHP-FPM Master Process │ │ │ │ Manages a pool of worker processes that execute PHP code │ │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ Worker1 │ │ Worker2 │ │ Worker3 │ │ Worker4 │ │ Worker5 │ │ │ │ (idle) │ │ (busy) │ │ (busy) │ │ (idle) │ │ (busy) │ │ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ Each worker process handles ONE request at a time. If all workers are busy, requests wait in a queue.
KEY INSIGHT

PHP-FPM workers are persistent processes. They don't die after each request (unlike CGI). This is good for performance, but bad for memory leaks — a leaking worker stays alive for thousands of requests, accumulating memory until it crashes.

⚙️ The Critical PHP-FPM Settings

Setting What It Does Default Production Value
pm Process manager type dynamic dynamic or ondemand
pm.max_children Maximum number of worker processes 5-10 (varies) Calculated based on RAM
pm.start_servers Number of workers created on startup 2-4 (varies) max_children / 4
pm.min_spare_servers Minimum idle workers (dynamic mode) 1-2 (varies) start_servers
pm.max_spare_servers Maximum idle workers (dynamic mode) 3-6 (varies) max_children / 2
pm.max_requests Requests per worker before restart 0 (unlimited) 500-1000
request_terminate_timeout Max execution time per request 0 (no limit) 30-60 seconds

🧮 How to Calculate pm.max_children

THE FORMULA: ═══════════════════════════════════════════════════════════════════ max_children = (Total RAM - RAM for OS - RAM for MySQL - RAM for Redis) ──────────────────────────────────────────────────────── Average RAM per PHP process EXAMPLE (8GB server): ═══════════════════════════════════════════════════════════════════ Total RAM: 8192 MB - OS + misc: -1024 MB - MySQL: -2048 MB (innodb_buffer_pool_size) - Redis: - 512 MB ───────────────────────────────────── Available for PHP: 4608 MB Average Laravel process: ~40 MB (with OpCache, sessions, etc.) max_children = 4608 / 40 = 115 processes CONFIGURATION: ═══════════════════════════════════════════════════════════════════ pm.max_children = 115 pm.start_servers = 28 (115 / 4) pm.min_spare_servers = 28 (same as start) pm.max_spare_servers = 57 (115 / 2) pm.max_requests = 500
⚠️ WARNING: DON'T GUESS

Setting pm.max_children too high → Server runs out of memory, crashes, OOM killer kills processes randomly.
Setting pm.max_children too low → Requests queue up, users see 502 errors, response times skyrocket.

HOW TO MEASURE AVERAGE PROCESS MEMORY
# Check memory usage of PHP-FPM processes
ps aux | grep "php-fpm" | awk '{sum+=$6} END {print sum/NR/1024 " MB"}'

# Or with more detail
watch -n 1 'ps aux | grep "php-fpm" | head -20'

🔄 Process Manager Modes: dynamic vs ondemand

Mode How It Works Pros Cons Best For
dynamic Maintains min/max spare idle workers Fast response (workers ready) Consumes memory even when idle High-traffic, consistent load
ondemand Creates workers only when needed, kills after idle time Saves memory during low traffic Cold start delay on first request Low-traffic, variable load, cron-heavy apps
static Fixed number of workers (max_children) Predictable, no spawning overhead Wastes memory if traffic is low Very high-traffic, predictable load
# dynamic mode (recommended for most Laravel apps)
pm = dynamic
pm.max_children = 100
pm.start_servers = 25
pm.min_spare_servers = 25
pm.max_spare_servers = 50
pm.max_requests = 500

# ondemand mode (memory saving)
pm = ondemand
pm.max_children = 100
pm.process_idle_timeout = 10s
pm.max_requests = 500

# static mode (predictable, no spawning)
pm = static
pm.max_children = 100
pm.max_requests = 500

💧 The Memory Leak Problem (pm.max_requests)

WITH pm.max_requests = 0 (DEFAULT - DANGEROUS) ═══════════════════════════════════════════════════════════════════ Worker memory usage over time: ┌─────────────────────────────────────────────────────────────────┐ │ │ │ 100MB ┤ ┌───────────│ │ 80MB ┤ ┌───┘ │ │ 60MB ┤ ┌───┘ │ │ 40MB ┤ ┌───┘ │ │ 20MB ┤ ┌───┬───┬───┬───┬───┬───┬───┬───┘ │ │ 0MB └┴───┴───┴───┴───┴───┴───┴───┴─────────────────────────────│ │ Day1 Day2 Day3 Day4 Day5 Day6 Day7 Day8 │ │ │ │ After 7 days: Worker has 100MB leak. 100 workers = 10GB RAM │ │ Server crashes. Restart. Repeat. │ └─────────────────────────────────────────────────────────────────┘ WITH pm.max_requests = 500 (PRODUCTION - SAFE) ═══════════════════════════════════════════════════════════════════ Worker memory usage over time: ┌─────────────────────────────────────────────────────────────────┐ │ │ │ 100MB ┤ │ │ 80MB ┤ │ │ 60MB ┤ │ │ 40MB ┤ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ │ 20MB ┤─┘ └─┘ └─┘ └─┘ └─┘ └─ │ │ 0MB └┴───┴───┴───┴───┴───┴───┴───┴─────────────────────────────│ │ │ │ Worker dies and respawns every 500 requests. │ │ Memory never accumulates. Server stable. │ └─────────────────────────────────────────────────────────────────┘
THE GOLDEN RULE OF PHP-FPM

Always set pm.max_requests to 500-1000. This is the single most important PHP-FPM setting for production stability. It prevents memory leaks from killing your server.

⏱️ request_terminate_timeout (The Safety Net)

THE PROBLEM

A single slow request (e.g., external API timeout, database deadlock) can hang a worker forever. That worker never processes another request. Over time, all workers hang. Server stops responding to ANY request.

THE SOLUTION
# php-fpm.conf
request_terminate_timeout = 60s

# If a request takes longer than 60 seconds, PHP-FPM kills the worker
# A new worker is spawned immediately
# Your server keeps running
RELATION TO max_execution_time

max_execution_time in php.ini is a soft limit (PHP can ignore in some cases).
request_terminate_timeout in php-fpm.conf is a hard kill — the process is terminated by the OS.

📊 Monitoring PHP-FPM

Enable status page:

# php-fpm.conf
pm.status_path = /status

# Nginx config
location ~ ^/status$ {
    include fastcgi_params;
    fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

Check status:

curl http://localhost/status

pool:                 www
process manager:      dynamic
start time:           1704067200
start since:          3600
accepted conn:        12500
listen queue:         0      ← If this grows, you need more workers
max listen queue:     50
listen queue len:     128
idle processes:       15
active processes:     10    ← Currently handling requests
total processes:      25
max active processes: 45    ← Peak concurrent requests
max children reached: 0      ← If >0, increase max_children
CRITICAL METRICS TO WATCH

📄 Sample Production Configuration (8GB Server)

; /etc/php/8.2/fpm/pool.d/www.conf

[www]
user = www-data
group = www-data
listen = /run/php/php8.2-fpm.sock
listen.owner = www-data
listen.group = www-data

; Process manager settings
pm = dynamic
pm.max_children = 100
pm.start_servers = 25
pm.min_spare_servers = 25
pm.max_spare_servers = 50
pm.max_requests = 500

; Request timeout
request_terminate_timeout = 60s
request_slowlog_timeout = 5s
slowlog = /var/log/php8.2-fpm-slow.log

; Status page
pm.status_path = /status
ping.path = /ping
ping.response = pong

; Security
; Prevent users from seeing environment variables
clear_env = no

; Child processes
catch_workers_output = yes
decorate_workers_output = no
📌 THE RULE: Never use default PHP-FPM settings in production. Calculate pm.max_children based on your RAM. Set pm.max_requests = 500. Enable the status page and monitor it. Your server will survive traffic spikes.

📝 Topic 16 Summary: PHP-FPM

Setting Default (Dev) Production Why
pm dynamic dynamic or ondemand Depends on traffic pattern
pm.max_children 5-10 (RAM - other) / 40MB Prevents OOM and queueing
pm.max_requests 0 500-1000 Prevents memory leaks
request_terminate_timeout 0 60s Kills hung requests
NEXT TOPIC PREVIEW

Topic 17: OpCache & PHP JIT — PHP's built-in performance superpowers. Why OpCache is mandatory for production. How JIT can make CPU-intensive code 3-5x faster.