How PHP-FPM Actually Runs Your Application
Explain the master/worker process architecture of PHP-FPM, configure a production pool with the static, dynamic, and ondemand process managers, and trace a single request from the web server through FastCGI into a PHP-FPM worker and back.
Almost every serious PHP deployment behind Nginx -- and a great many behind Apache -- runs PHP through PHP-FPM (FastCGI Process Manager), not through an embedded module. FPM is the piece that turns "a PHP script" into "a production service": it keeps a pool of pre-forked PHP processes warm and ready, hands each incoming request to one of them over the FastCGI protocol, recycles workers to guard against memory leaks, and gives you knobs -- pm.max_children, slow-request logging, graceful reload -- that matter enormously for uptime and throughput under real traffic. This lesson opens up FPM's internals: the master process, the worker pool, the three process-manager strategies, and the full request lifecycle, with real php-fpm.conf and pool snippets you'd actually deploy.
Master Process and Worker Processes
PHP-FPM runs as two kinds of process. The master process starts first (usually as root, so it can bind privileged resources and read config), reads php-fpm.conf and every pool definition under it, then forks a number of child worker processes and drops privileges on them to the configured pool user/group. The master process itself never executes PHP code and never touches a request directly -- its whole job is supervision: it owns the listening socket, accepts new connections, hands each one off to an idle worker, restarts workers that die or get killed, and reloads configuration on signal.
Worker processes are the ones that actually run your application. Each worker is a full PHP interpreter (Zend Engine, loaded extensions, OPcache shared memory attached) sitting in a loop: wait for a request handed to it by the master, bootstrap PHP's request-scoped state, execute the target script from clean init state, flush output, then reset and loop back to waiting. Workers are single-threaded and handle exactly one request at a time -- there is no concept of a worker serving two requests concurrently, which is precisely why the pool's worker count sets your true concurrency ceiling.
Because each worker is a separate OS process, workers are isolated from one another: a fatal error, a segfault in a buggy extension, or a runaway memory leak in one worker cannot directly corrupt another worker's state or crash the whole pool. The master simply notices the dead worker and forks a replacement. This process-per-request-slot isolation is one of FPM's biggest reliability wins over threaded models, at the cost of higher per-worker memory overhead than threads would use.
• Master process: reads config, owns the listening socket, forks/supervises workers, never runs application code itself. • Worker process: a full PHP interpreter that executes exactly one request at a time, then resets and waits for the next. • Workers are isolated OS processes -- one worker crashing does not take down the pool. • Worker count is your real concurrency ceiling: N workers busy means request N+1 queues.
Process Managers: static, dynamic, ondemand
FPM ships three process-manager (pm) strategies, set per pool with the pm directive, and each trades startup cost against memory usage differently. pm = static spawns exactly pm.max_children workers at startup and keeps that exact number alive forever, regardless of load -- no forking during traffic spikes, completely predictable memory usage, but you pay for max_children workers' worth of memory 24/7 even at 3am with zero traffic. This is the standard choice for high-traffic production APIs where request latency during a burst matters more than idle memory.
pm = dynamic starts with pm.start_servers idle workers and scales the pool up and down between pm.min_spare_servers and pm.max_spare_servers as load changes, never exceeding pm.max_children. It's the common middle-ground default: memory usage tracks actual traffic, but a sudden spike still has to pay the fork cost for new workers before it can serve the extra load, and PHP process forking is not instant (bootstrapping autoloaders, opcache attachment, extension init all cost real milliseconds).
pm = ondemand starts with zero workers and forks one only when a request actually arrives, killing idle workers after pm.process_idle_timeout seconds of inactivity. It uses the least memory of the three when idle -- ideal for low-traffic internal tools, staging environments, or sites with long quiet periods -- but every request that arrives after an idle gap pays the full fork-and-bootstrap cost, making it the wrong choice for latency-sensitive, spiky production traffic.
• static -- fixed N workers always running; best predictability, highest constant memory. • dynamic -- scales between min/max spare servers up to max_children; common default balance. • ondemand -- forks on demand, kills idle workers; lowest idle memory, highest cold-start latency. • pm.max_children is the hard ceiling in all three modes -- size it to (available RAM) / (memory per worker).
The Request Lifecycle Through FastCGI
PHP-FPM does not speak raw HTTP -- it speaks FastCGI, a binary protocol designed so a web server can hand off dynamic-content generation to a separate long-lived process pool without the overhead of spawning a new process per request (the old CGI model). The web server (Nginx, or Apache with mod_proxy_fcgi) terminates the actual HTTP connection with the browser, then translates the request into FastCGI records -- request metadata (method, headers, query string, script path) go in FCGI_PARAMS records, and any request body streams in FCGI_STDIN records -- and sends them to FPM over a Unix domain socket or a TCP socket.
The full path looks like this: Browser sends an HTTP request to Nginx (or Apache). Nginx matches the request against a location ~ \.php$ block and, instead of serving a static file, opens a FastCGI connection to FPM's listen address. FPM's master process accepts that connection and dispatches it to an idle worker (or queues it if none are idle and max_children is already reached). The worker sets up superglobals ($_GET, $_POST, $_SERVER, etc.) from the FastCGI params, executes the target PHP script from a clean state, and streams its output back as FCGI_STDOUT records. FPM returns those records to Nginx, which repackages them into a proper HTTP response and sends it to the browser. The worker then resets (SHM/OPcache stays attached, but request-scoped state is torn down) and returns to the idle pool.
Unix domain sockets (a file-based socket, e.g. /run/php/php8.2-fpm.sock) are the standard choice when Nginx and FPM live on the same machine: they skip the TCP/IP stack entirely, avoiding port allocation and network-stack overhead, and are typically a bit faster with lower CPU cost than TCP for local IPC. TCP sockets (127.0.0.1:9000, or a private network address) are used when the web server and FPM run on different hosts or in different containers -- Docker's usual pattern -- since a Unix socket can't cross that boundary without a shared volume.
• FastCGI is a binary protocol for handing dynamic requests to a persistent process pool -- not raw HTTP. • Request path: Browser -> Nginx/Apache -> FastCGI -> FPM master -> dispatched to an idle worker -> PHP executes -> response streams back. • Unix sockets: faster, local-only, the default for same-host Nginx + FPM. • TCP sockets: required across hosts/containers; slightly more overhead than a Unix socket.
Graceful Reload, Slow Requests, and Worker Lifecycle Limits
Deploying new code or config to FPM should never drop in-flight requests. Sending the master process SIGUSR2 (or running systemctl reload php8.2-fpm, or the FPM binary's -R reload) triggers a graceful reload: the master starts new workers with the updated configuration, lets existing workers finish whatever request they're currently handling, then retires the old workers -- there's no window where connections are refused. This is different from a hard restart (SIGTERM / stop-then-start), which would kill in-flight requests immediately.
Two config values protect a pool's long-term health. pm.max_requests recycles a worker (kills it and forks a fresh replacement) after it has served that many requests -- a cheap, effective defense against slow memory leaks in application code or extensions that would otherwise accumulate across a worker's lifetime; production pools commonly set this to something like 500. request_slowlog_timeout, paired with slowlog, makes FPM dump a full PHP backtrace of any request still running past that many seconds into a dedicated slow-request log -- an essential production diagnostic for finding the one endpoint quietly hanging on a slow external API call, well before request_terminate_timeout would kill it outright.
Each worker also has hard-isolation properties worth remembering: a worker's process memory, OPcache-attached shared memory aside, is private to it -- global state set during one request (module-level statics, for instance) does not leak into the next request unless it's stored somewhere external (session, cache, database). This "share-nothing between requests, except explicitly shared memory like OPcache" model is why PHP applications don't generally suffer the same request-bleed bugs that persistent-process languages without per-request teardown can.
• Graceful reload (SIGUSR2 / systemctl reload): new workers spun up, old ones finish in-flight requests, zero dropped connections. • pm.max_requests: recycle a worker after N requests -- cheap leak mitigation; common value: 500. • request_slowlog_timeout + slowlog: dumps a PHP backtrace for requests running longer than the threshold. • request_terminate_timeout: hard-kills a request that runs too long (separate from the slowlog threshold).
Real php-fpm.conf and Pool Configuration
pm.max_children for static mode is the fixed worker count; for dynamic it's the absolute ceiling the pool can scale up to. listen.owner/listen.group/listen.mode matter only for Unix sockets -- they control which OS user (typically the Nginx worker user) may connect. Reload with systemctl reload php8.2-fpm (graceful, SIGUSR2 under the hood) -- never restart for a routine deploy if you want zero dropped connections.
; /etc/php/8.2/fpm/php-fpm.conf (global settings)
[global]
pid = /run/php/php8.2-fpm.pid
error_log = /var/log/php8.2-fpm.log
daemonize = yes
; /etc/php/8.2/fpm/pool.d/www.conf (a pool definition)
[www]
user = www-data
group = www-data
; Unix socket -- fastest for same-host Nginx + FPM
listen = /run/php/php8.2-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
; -- or, across hosts/containers, a TCP socket instead: --
; listen = 127.0.0.1:9000
pm = dynamic
pm.max_children = 20 ; hard ceiling -- size to RAM / (mem per worker)
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 500 ; recycle workers periodically -- leak mitigation
; Slow-request diagnostics
request_slowlog_timeout = 5s
slowlog = /var/log/php8.2-fpm-slow.log
; Hard timeout -- kills a request that runs too long
request_terminate_timeout = 30s
pm.status_path = /fpm-status ; enables FPM's built-in status pageCode Examples
This is the Nginx half of the picture: Nginx serves static files directly and itself, but any request matching \.php$ is handed off over FastCGI to the FPM Unix socket rather than being served as a file. fastcgi_param SCRIPT_FILENAME tells FPM exactly which script on disk to execute -- FPM has no directory-listing logic of its own, it only ever runs the single script path Nginx tells it to. Swapping that one line to fastcgi_pass 127.0.0.1:9000; is the entire change needed to move from a Unix socket to TCP -- everything else about the request lifecycle stays identical.
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
Common mistake: Forgetting fastcgi_param SCRIPT_FILENAME (or getting root wrong) leads to FPM's classic "No input file specified" -- it received a connection but was never told a valid script path to execute.
FPM's status page (enabled by pm.status_path) exposes exactly the internals this lesson describes: active vs idle worker counts, how many total processes exist, and -- critically -- the listen queue, which is non-zero exactly when every worker is busy and new connections are backing up waiting for a free one. Monitoring listen queue and max active processes over time is the standard way to decide whether pm.max_children needs to go up. Expected result: pool=www active=3 idle=17 total=20 max active reached=12 queue=0 A sustained non-zero listen queue under normal traffic is the single clearest signal that pm.max_children is undersized for the load the pool is actually receiving.
server {
listen 80;
server_name example.com;
root /var/www/example.com/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}<?php
// A tiny script that queries FPM's built-in status endpoint
// (enabled via pm.status_path = /fpm-status in the pool config)
// and reports whether the pool is under pressure.
$statusUrl = "http://127.0.0.1/fpm-status?json";
$json = file_get_contents($statusUrl);
$status = json_decode($json, true);
printf(
"pool=%s active=%d idle=%d total=%d max active reached=%d queue=%d\n",
$status['pool'],
$status['active processes'],
$status['idle processes'],
$status['total processes'],
$status['max active processes'],
$status['listen queue'],
);
if ($status['listen queue'] > 0) {
fwrite(STDERR, "WARNING: requests are queuing -- pm.max_children may be too low\n");
}PHP-FPM Request Path
One request, one worker, for its entire duration -- concurrency is bounded by pm.max_children.
Browser
| HTTP request
v
+-----------------+
| Nginx / Apache | terminates HTTP, matches *.php
+-----------------+
| FastCGI (Unix socket or TCP)
v
+---------------------+
| PHP-FPM Master | owns the socket, supervises workers
| (reads php-fpm.conf) | forks / restarts / reloads
+---------------------+
| dispatches to an idle worker
v
+----------------------------------------------+
| PHP-FPM Workers (pool sized by pm.* config) |
| [ worker 1 ] [ worker 2 ] ... [ worker N ]|
+----------------------------------------------+
| each worker executes ONE request at a time
v
+-------------------+
| PHP Application | your script runs, builds the response
+-------------------+
| output streams back up through FPM -> Nginx -> BrowserImportant Rules
pm.max_children is the hard concurrency ceiling for a pool no matter which pm strategy is chosen -- size it from available RAM divided by realistic per-worker memory, not guesswork.
Use static for predictable high-traffic production, dynamic as the general default, ondemand for low-traffic/staging where idle memory matters most.
Always reload (SIGUSR2 / systemctl reload) for config/code deploys, never a hard restart, unless you're fine dropping in-flight requests.
Unix sockets for same-host web server + FPM; TCP sockets when they're on different hosts or containers.
Set pm.max_requests to a sane non-zero value in production as cheap insurance against slow memory leaks.
Why This Matters in Production
Undersized pm.max_children is one of the most common causes of "random 502s under load" reports: Nginx gets a connection refused or a timeout from FPM because every worker is busy and the listen queue backlog has been exhausted, and the fix is almost never "add more servers" -- it's tuning pm.max_children against actual measured per-worker memory and watching the FPM status page's listen queue metric. Teams running FPM in Kubernetes or Docker typically switch to TCP sockets (or a Unix socket on a shared volume between an Nginx sidecar and the FPM container) and set pm = dynamic with conservative bounds per pod, letting the orchestrator handle horizontal scaling rather than trying to size one giant pool for peak traffic.
pm = dynamic with pm.max_children set far above what the server's RAM can actually support
Calculate max_children from (available RAM for PHP) / (typical resident size per worker, measured under real load), then round down for headroom
If every worker can spike to, say, 80MB and the box has 2GB free for PHP, max_children should be around 20-25, not 100 -- past that the OS starts swapping or the OOM killer starts terminating workers under load, which is far worse than a request briefly queuing.
Restarting FPM (systemctl restart php8.2-fpm) as part of every deploy
Reload FPM (systemctl reload php8.2-fpm, which sends SIGUSR2) so in-flight requests finish on old workers while new workers spin up with the new code
A restart tears down the listening socket and all workers immediately, dropping any request that was mid-flight; a reload keeps the socket open and lets old workers drain naturally while new ones take over new connections.
[multiple-choice] What does the PHP-FPM master process do during a normal request? A. It executes the PHP script itself and returns the output directly B. It never touches request execution -- it only accepts the connection and dispatches it to an idle worker process C. It runs the request in a new thread inside itself D. It forwards the request to Apache's mod_php for execution
B. It never touches request execution -- it only accepts the connection and dispatches it to an idle worker process — The master process only supervises: it owns the listening socket and hands off each connection to an idle worker. Actual PHP execution happens entirely inside worker processes.
[multiple-choice] Which process manager mode keeps a fixed number of workers running at all times, using the most predictable but also the most constant amount of memory? A. ondemand B. dynamic C. static D. adaptive
C. static — pm = static forks exactly pm.max_children workers at startup and never changes that count regardless of load, trading constant memory use for perfectly predictable capacity.
[true-false] A graceful reload (SIGUSR2) of PHP-FPM drops any requests that are currently in flight on existing workers. A. True B. False
B. False — False -- a graceful reload starts new workers with updated config while letting existing workers finish their current requests before retiring; no in-flight connection is dropped.
[multiple-choice] Why would you choose a Unix domain socket over a TCP socket to connect Nginx to PHP-FPM? A. Unix sockets support encryption and TCP sockets do not B. Unix sockets work across separate hosts while TCP does not C. When Nginx and FPM run on the same host, a Unix socket skips the TCP/IP stack and is typically faster with lower overhead D. TCP sockets cannot be used with PHP-FPM at all
C. When Nginx and FPM run on the same host, a Unix socket skips the TCP/IP stack and is typically faster with lower overhead — Unix domain sockets are file-based, local-only IPC that avoid TCP/IP stack overhead, making them the faster default when the web server and FPM share a host; TCP is needed only when they're on different hosts or containers.
Given a server with 4GB of RAM reserved for PHP-FPM and workers that average 60MB resident memory each under load, write the pm.* block of a pool config using dynamic process management sized appropriately, and explain in a comment why you chose those numbers.
pm = dynamic\npm.max_children = 50 ; ~4096MB/60MB ~= 68 theoretical, rounded down for headroom\npm.start_servers = 10\npm.min_spare_servers = 5\npm.max_spare_servers = 20\npm.max_requests = 500
Leave headroom below the theoretical (4096MB / 60MB) ceiling for memory spikes and other processes on the box; pick min/max spare servers as a sensible fraction of max_children.