gap·map

PHP runtime model

[ middle depth ]

How the PHP request lifecycle works

Every request starts from zero. The web server hands the request to PHP, PHP builds the superglobals ($_GET, $_POST, $_SERVER, $_SESSION) fresh, loads and runs your script, sends the output, then tears the whole thing down. Nothing you put in a normal variable, a static, or a global survives to the next request. This is the shared-nothing model, and almost every PHP gotcha traces back to it.

Wrong "I'll cache the config in a global array so the next request skips the DB lookup." There is no next request in the same memory. That array is gone the moment the script ends. Cross-request state has to live somewhere outside the process: a session store, the database, or Redis/Memcached. The upside is that one broken request cannot poison the next one, because there is nothing shared to corrupt.

A consequence people forget: your database connection is re-established on every request too, unless you run an external pooler (pgbouncer, ProxySQL) or use persistent connections carefully. Connection setup is not free, and under load it shows up.

How PHP-FPM worker pools handle traffic

PHP-FPM runs a pool of worker processes. One worker serves exactly one request at a time, start to finish. Your concurrency ceiling is just the number of workers, set by pm.max_children.

pm = dynamic
pm.max_children = 20      ; hard cap on concurrent requests
pm.start_servers = 4
pm.max_requests = 500     ; recycle a worker after N requests to bound leaks

The classic outage: one endpoint calls a slow upstream (a third-party API at 8 seconds, a slow query). Each request in that endpoint pins a worker for 8 seconds doing nothing but waiting. Traffic does not even have to spike. Enough slow requests arrive together, all 20 workers are stuck waiting, the queue fills, and nginx returns 502 or 504 because it cannot hand off the request. The fix is rarely "raise max_children" alone (that just trades 502s for an out-of-memory box). Put timeouts on upstream calls, and move slow work off the request path.

What OPcache does and how deploys break it

PHP compiles your source to opcodes on the way in. OPcache stores those compiled opcodes in shared memory so workers skip recompilation. In production you set opcache.validate_timestamps=0 so PHP stops running a stat on every file on every request.

That setting is also the source of a real incident: you deploy new code, and the site keeps running the old code, because OPcache was told never to check file mtimes. You have to reset OPcache explicitly (opcache_reset(), or reload/restart FPM) as part of the deploy. Containers get this for free because a new image is a new process, but symlink-swap deploys on a long-lived FPM do not.

[ senior depth ]

The lifecycle in engine terms

An FPM worker boots once and runs many requests. Module init (MINIT) fires once at worker start: extensions register, opcache.preload runs here. Then a loop: request init (RINIT) builds the superglobals and per-request state, the Zend engine compiles your source to opcodes and the VM executes them, request shutdown (RSHUTDOWN) frees everything the request allocated. Module shutdown (MSHUTDOWN) only happens when the worker itself dies.

The distinction that trips people: the worker process is reused, but the request memory is not. OPcache lives in shared memory mapped across every worker in the pool, so compiled opcodes are shared and persistent. Your request-scoped variables, statics, and superglobals are torn down at RSHUTDOWN every single time. Worker reuse buys you a warm opcode cache and skipped MINIT, nothing more.

Sizing an FPM pool

Concurrency equals worker count, so pm.max_children is the number that decides whether you serve traffic or emit 502s. The honest formula:

pm.max_children ≈ (RAM budget for PHP) / (avg worker RSS)

Leave headroom so a traffic burst does not push the box into swap. static pre-forks all workers (predictable RAM, zero spawn latency, best for dedicated boxes); dynamic scales between min and max. pm.max_requests recycles a worker after N requests to cap slow leaks. Two backpressure knobs interviewers like to hear: listen.backlog (accept-queue depth before the front-end sees refusals) and request_terminate_timeout (kill a worker wedged on a hung upstream so it rejoins the pool).

OPcache, preloading, and the JIT reality

Size the SHM (opcache.memory_consumption), the interned strings buffer, and opcache.max_accelerated_files to fit your codebase; when the cache fills it thrashes and restarts. Preloading (7.4+) compiles and links chosen classes into memory at MINIT so they exist before any request and are never re-parsed. The cost: preloaded code cannot change without an FPM restart.

The JIT is the most oversold PHP 8 feature. Wrong "we turned on JIT and our API got twice as fast." For typical I/O-bound request handlers the measured effect is a few percent, sometimes negative, because the bottleneck is the database and the network, not opcode dispatch. Real JIT wins are CPU-bound loops: image processing, numerics, tight parsing. If your interviewer asks where JIT helps, the credible answer names the workload, not a blanket speedup.

Long-running runtimes and state leakage

Swoole, RoadRunner, and FrankenPHP keep the interpreter alive and feed it many requests without tearing down state, which is where FPM's shared-nothing safety net disappears (contrast: Node is long-running by default, and node-runtime owns that model). Now a static counter accumulates across requests, a container singleton pins a stale DB connection that throws after the peer closes it, and worst, per-user state in a global bleeds into the next user's request (real Symfony session-leak reports exist). Porting FPM code to worker mode is mostly finding every place that assumed request end would clean up for you. What gets graded is whether you can name a concrete leak and its fix (reset state between requests, stop mutating globals) rather than reciting that worker mode is "faster."

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

unlocks

more PHP

was this useful?