40% off everything — use code SAVE40 at checkout · ends in …
Skip to content
phpguru.store
Lesson 1 of 12 4 min read Free preview

How PHP executes — opcache, preloading, the JIT and PHP-FPM

The request lifecycle from socket to bytecode, what each cache layer removes, why the JIT rarely matters for web apps, and how to size PHP-FPM from numbers instead of folklore.

The lifecycle you've been ignoring

Every PHP request goes through the same stages: the web server accepts a connection and hands it to a PHP-FPM worker process; that worker reads your entry file, lexes and parses it into an AST, compiles the AST into opcodes for the Zend VM, executes the opcodes (which triggers more files being loaded and compiled), sends output, and then tears everything down — every variable, every object, every open connection — before taking the next request.

That last part is PHP's defining property: shared-nothing. Each request starts from zero. It's why PHP is hard to break (no leaked state between users) and why "just keep it in memory" isn't an option without help. Everything in this course that touches performance is really about what to keep *outside* the request — in opcache, in APCu, in Redis, in the database — and what to recompute.

Opcache: never compile twice

Compilation is expensive — a framework request may touch 300 files. Opcache stores the compiled opcodes in shared memory, so after the first request, execution starts at "execute". Settings that matter on a production server:

opcache.enable=1
opcache.memory_consumption=256        ; MB; watch opcache_get_status()['memory_usage'] and size to fit
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000   ; must exceed your file count (find . -name '*.php' | wc -l)
opcache.validate_timestamps=0         ; never stat() files; your deploy resets the cache (lesson 12)
opcache.save_comments=1               ; attributes and docblocks need this

With validate_timestamps=0, a deploy that only copies files changes nothing until you reset the cache. That's a feature: the switch is atomic and under your control. Check the hit rate: opcache_get_status()['opcache_statistics']['opcache_hit_rate'] should sit above 99%. If oom_restarts climbs, raise memory_consumption.

Preloading: skip even the load

PHP 7.4+ can load a set of files into opcache once at FPM startup and keep them linked and ready — classes don't just skip compilation, they skip autoloading and linking. For a framework core that's 5–15% off every request.

<?php
// preload.php  (opcache.preload=/var/www/app/preload.php)
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__ . '/src')) as $file) {
    if ($file->getExtension() === 'php') {
        opcache_compile_file($file->getPathname());
    }
}

The cost: preloaded files can't change without an FPM restart, and a fatal in a preloaded file stops FPM starting. Preload your stable core (framework, domain classes), not your controllers.

The JIT — and why you'll mostly leave it off

PHP 8's JIT compiles hot opcode sequences to machine code. It's a big win for CPU-bound loops — image processing, numeric work, a Mandelbrot benchmark. A typical web request is I/O-bound: it waits on MySQL, Redis and the network, and executes comparatively little PHP. Benchmarks of real apps show 0–5% from the JIT, sometimes negative. Enable it for a worker that crunches data; measure before enabling it for the web.

opcache.jit=tracing
opcache.jit_buffer_size=64M

PHP-FPM: the process manager is your concurrency model

FPM keeps a pool of worker processes; each handles one request at a time. Your maximum concurrency is pm.max_children, full stop. Set it from memory:

max_children = (RAM available to PHP) / (memory per worker)

Measure the second number — don't guess. ps -o rss= -C php-fpm8.3 | awk '{s+=$1} END {print s/NR/1024 " MB avg"}' after an hour of traffic. A framework app is 30–60 MB; a WordPress site is 80–120 MB. On a 4 GB VPS with MySQL taking 1 GB and the OS 500 MB, 2.5 GB / 50 MB ≈ 50 workers.

[www]
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 15
pm.max_requests = 1000              ; recycle workers to contain slow leaks
request_terminate_timeout = 30s     ; kill runaway requests before they eat the pool
pm.status_path = /fpm-status        ; restrict by IP in the web server
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 2s        ; stack traces of slow requests, for free

pm = static (a fixed pool) is right for a dedicated app server: no fork cost, predictable memory. pm = ondemand is right for a low-traffic tenant on a shared box.

The status page tells you the truth about capacity: if listen queue is ever above zero, requests are waiting for a free worker — you're at max_children. Either the workers are too slow (fix queries, add caching) or you need more of them (more RAM, or another server — lesson 10).

Where the time actually goes: a first profile

Before tuning anything, look. hrtime() around the request gives total time; the query log from the Practice course gives database time; the difference is PHP. For finer detail, Xdebug's profiler or the lower-overhead excimer extension produces a call graph you can open in a viewer. Ten minutes with a profile beats a day of guessing; the usual culprits, in order, are: N+1 queries, missing indexes, serialising large objects into the session, and autoloading without an optimised classmap.

The numbers that matter for the rest of this course

  • p95 response time, not the average (the average hides the slow 5%).
  • Requests per second per worker — throughput divided by max_children is your headroom.
  • Listen queue length — the earliest warning that you're out of capacity.
  • Opcache hit rate and memory — anything under 99% or near full is a config bug.

Every optimisation later — caching, queues, read replicas — is judged against these.

Exercises

  1. Run opcache_get_status() on your server (or a local FPM) and record hit rate, memory usage and cached files. Change max_accelerated_files to below your file count and watch what happens.
  2. Measure your app's memory per worker under load (use ab -n 2000 -c 20 or wrk against a real page). Compute max_children for your server. Compare with what's configured.
  3. Enable the slowlog with a 1-second threshold, hit the slowest page you have, and read the stack trace it produces. That's your first profile.

Next: the type system. PHP 8.3 has one — using it properly removes whole categories of runtime bugs.

That was lesson 1. The other 11 are one payment away.

Next up: The modern type system, used on purpose. Buy once, read every lesson online with your progress saved, and keep the whole course as a Word file.

Get the course · ₹1,999$49