Skip to content

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 = 500

pm.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=tracing

validate_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.ini files. php -v and phpinfo() under FPM can report different extensions and settings. Always check the FPM config when debugging production.
  • realpath_cache_size defaults low; Symfony-sized trees benefit from realpath_cache_size=4096k and realpath_cache_ttl=600.
  • A 500 from FPM with an empty body usually means the worker died — check php-fpm.log and the pool's slowlog before blaming your code.