Lesson 1 of 9 · 25m
Running PHP 8.x in Production: FPM, Opcache, and the Request Lifecycle
How PHP actually executes requests, how to tune FPM workers, and why opcache is non-negotiable.
You inherit a PHP app that "feels slow" and nobody can tell you whether the bottleneck is the code, the interpreter, or the process manager. Before touching a single route, you need a mental model of how a PHP request actually executes — because PHP's execution model is unusual and it shapes everything: how you scale, how you cache, and how you debug.
Shared-nothing, per request
PHP runs each HTTP request in a fresh interpreter context: new symbol table, new heap, dead afterwards. There is no application server holding state between requests like in Java or Node. PHP-FPM (FastCGI Process Manager) keeps a pool of worker processes that nginx or Apache hands requests to. When a request finishes, everything in userland memory is discarded. State lives in the database, Redis, the filesystem, or opcache — never in your objects.
This is PHP's superpower: a worker can crash, leak, or wedge, and the pool survives. It also means static caches inside a request die with it, and "warmup" work (config loading, container building) runs on every request unless you cache it in bytecode or shared memory.
Tuning FPM
php-fpm is configured per-pool (commonly /etc/php/8.3/fpm/pool.d/www.conf on Debian/Ubuntu, PHP 8.3 being the current stable line). The three settings that matter:
pm = dynamic
pm.max_children = 40
pm.max_requests = 500pm.max_children is the concurrency ceiling. Size it on memory: check a real worker's RSS under load (ps -o rss= -C php-fpm8.3), then max_children = (RAM for PHP) / worker_rss. On a 2 GB box reserving 512 MB for PHP, with workers at ~64 MB, you get about 8 — not the default 50 that's installed assuming you know better.
pm.max_requests recycles workers so accumulated memory leaks (yours or a library's) can't grow forever. 500–1000 is sane; zero means never recycle.
Opcache is not optional
Since PHP 5.5, opcache ships in core. Without it, every request re-tokenizes and re-compiles every file. In /etc/php/8.3/fpm/conf.d/10-opcache.ini:
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.jit=tracingvalidate_timestamps=0 means opcache never checks if files changed — maximum speed, but you must reset opcache on deploy (php-fpm reload or opcache_reset() via a protected endpoint). Forgetting this is the classic "I deployed and nothing changed" bug.
jit=tracing exists in PHP 8.x, but for typical I/O-bound web workloads it rarely helps; measure before keeping it on.
Try this
On a dev box, run php -r 'var_dump(opcache_get_status()["memory_usage"]);' under PHP-FPM (via a temporary script, since opcache lives in the FPM SAPI, not CLI). Watch used_memory climb with traffic. Then compute pm.max_children from real RSS numbers for one of your own pools.
Pitfalls
- CLI and FPM have separate
php.inifiles.php -vandphpinfo()under FPM can report different extensions and settings. Always check the FPM config when debugging production. realpath_cache_sizedefaults low; Symfony-sized trees benefit fromrealpath_cache_size=4096kandrealpath_cache_ttl=600.- A
500from FPM with an empty body usually means the worker died — checkphp-fpm.logand the pool'sslowlogbefore blaming your code.