What happens when PHP runs your code

A request walks from the SAPI to the Zend VM — lexing, compilation to opcodes, the OPcache lookup that skips both, and execution.

Alden PikeUpdated: 8 min read

Deploy a change, load a page, load it again. The second response comes back faster, on identical code and identical input. Nothing in the application decided that — the gap sits underneath it, in work the engine did once and did not have to repeat.

How much faster is a measurement question, and I have not measured a cold and warm FPM pair on hardware I can describe here; producing that number without fooling yourself is the subject of benchmarking PHP without lying to yourself. The question here is structural: what did the second request skip?

The SAPI boundary

PHP does not run your file. A SAPI does — the command line binary, or under a web server, PHP-FPM. An FPM worker sits in a loop: take a request over FastCGI, hand the engine the script path and the request environment, run it, flush the response, wait for the next one.

Around that call sit request startup and shutdown. Startup builds the request-scoped world: a fresh memory arena, a per-request hook in each loaded extension, and the superglobals — of which $_SERVER, $_REQUEST and $_ENV materialize on first access under the default auto_globals_jit rather than at startup. Shutdown tears the arena down.

“Shared nothing” is the consequence. Your variables, your static properties and the class definitions compiled during a request do not survive into the next one, whatever the worker kept underneath. The process does keep things — it stays alive, its extensions stay loaded, and it maps a region of shared memory that matters more here than the rest.

Source to opcodes

Asked for a file it has not seen, the engine reads the bytes and runs them through three stages. The lexer turns characters into tokens, the parser turns tokens into an abstract syntax tree, and the compiler walks that tree emitting opcodes.

Three stages is a simplification, worth marking as one: the real compiler does more than translate, and some of that work shows in its output. Arithmetic on literal operands is folded during compilation, so 2 ** 16 leaves behind the number and no arithmetic instruction.

The unit of all of it is the file. Compiling one file produces an op array for its top-level code plus one per function and method it defines. Nothing is per-class or per-call: reference one function from a two-thousand-line file and the whole file goes through the compiler.

What OPcache stores, and what it does not

OPcache sits between “the engine is asked for a file” and “the lexer starts”, keyed by the file path as the engine resolved it. On a miss the file is compiled and the result written into a shared memory segment every worker in the pool can read. On a hit the stored op arrays are attached to the request and the lexer, the parser and the compiler are skipped.

Those are the only things skipped. A cache hit does not skip execution. Every opcode in every op array a request touches runs on that request, cached or not, and OPcache holds no opinion about the values those opcodes produce. What the second request avoided was the cost of turning text into instructions, once. Everything those instructions themselves cost is still charged on every request, and the JIT is the feature aimed at that remaining half — what the JIT reaches and what it does not turns out to be a narrower story than the name suggests.

Whether staleness is checked depends on opcache.validate_timestamps. With it on, the engine compares the mtime on disk against the one in the cache entry, at most once per opcache.revalidate_freq seconds, and treats a newer file as a miss. With it off, the entry is trusted until something invalidates it explicitly.

The VM is a loop over opcodes

An op array is a flat sequence of instructions, not a tree. Each instruction carries a handler, up to two operands and a slot for its result, and the virtual machine loops over the sequence: take the next instruction, call its handler, advance. A call pushes an execution frame holding the callee’s variables, and returning pops it.

Operands come mostly in two flavors. A CV is a compiled variable — a numbered slot standing in for a named userland variable, resolved while compiling so no runtime lookup by name is needed. A T is a temporary holding an intermediate result. Both are slots rather than values: sixteen bytes on a 64-bit build carrying a type tag and either a small payload or a pointer to something larger, so an opcode that reads an array element costs whatever the structure behind that pointer costs rather than what the instruction count suggests. Two statements show the shape:

<?php

// total.php

declare(strict_types=1);

$total = (int) $argv[1] + 5;
echo $total, PHP_EOL;
php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x10000 total.php 7

The dump goes to standard error, ahead of the script’s own output. Both listings below drop the one header comment naming the file’s absolute path. On PHP 8.5.9 the first reads:

$_main:
     ; (lines=7, args=0, vars=2, tmps=4)
     ; (before optimizer)
     ; return  [] RANGE[0..0]
0000 T2 = FETCH_DIM_R CV1($argv) int(1)
0001 T3 = CAST (long) T2
0002 T4 = ADD T3 int(5)
0003 ASSIGN CV0($total) T4
0004 ECHO CV0($total)
0005 ECHO string("\n")
0006 RETURN int(1)

The single line of arithmetic is four instructions: fetch element 1 of $argv into a temporary, cast it, add the literal, store the sum in the slot standing for $total.

That is the compiler’s output, not the cache’s, as the flag name and header comment say. OPcache runs an optimizer over an op array before storing it, so the form the VM walks is what 0x20000 prints:

$_main:
     ; (lines=7, args=0, vars=2, tmps=2)
     ; (after optimizer)
0000 T2 = FETCH_DIM_R CV1($argv) int(1)
0001 T3 = CAST (long) T2
0002 T2 = ADD T3 int(5)
0003 ASSIGN CV0($total) T2
0004 ECHO CV0($total)
0005 ECHO string("\n")
0006 RETURN int(1)

Same seven instructions, two temporary slots instead of four. The first listing references three temporaries but declares four, so one was never used at all: the optimizer drops that one and folds the addition’s result back into T2, whose value the cast had already consumed.

The notation itself — what the letters in front of the operands mean, and why two tools print different programs for one function — is a skill the rest of this archive assumes.

Tidying is the least of it. Run the same two flags over a function holding a $base = 5 local, a dead if (false) branch and a declared return type, and ten instructions become four: the constant is propagated into the addition, the unreachable branch and the trailing implicit return are dropped, and the sum is written straight into a compiled variable with no temporary at all.

Autoloading decides how much reaches the compiler

To put a figure on the compile side I generated 300 small class files — twelve methods each, about 414 KiB of source — and timed a loop requiring them all with OPcache disabled, so every run compiled every file. Apple M4 Pro, macOS, PHP 8.5.9 CLI, NTS, JIT disabled, timing taken with hrtime() around the require loop, so filesystem reads are included. The median of 15 runs was 7.69 ms, spanning 7.61 to 7.89 ms, a range of 3.6% of the median: roughly 26 µs per file. Read those as relative, not absolute — one laptop, not quiesced. The shape is the portable part: cost proportional to file count, at tens of microseconds each.

Autoloading decides which files reach the compiler. Under Composer’s autoloader a class no code path references is never required, never compiled and never cached, while a referenced class pulls its file through the sequence above. Cold-start cost is a function of how many distinct files a request touches, and the dependency graph moves that number far more than anything inside a function body. Trimming instructions from a hot loop does not recover 26 µs apiece for files that never needed to load.

An optimized classmap is a narrower lever: it replaces the autoloader’s per-class filesystem probing with one array lookup, removing stat calls, but it does not reduce how many files are compiled. I had not measured that difference when this was written; measured since, on a Symfony skeleton, it removed 508 filesystem probes and changed the cold request by nothing this method could see.

A bigger lever than either is moving the work out of the request entirely. A framework that compiles its service container does the resolution once at build time and writes the result out as generated code, so a request loads one file and calls the constructors directly instead of loading the definition machinery and resolving through it on every call. That file is compiled once and read from the cache afterwards, which makes the whole advantage contingent on OPcache being on — what compiled DI containers actually buy you measures both halves of the saving, and the container size past which compiling the generated file costs more than the definitions it replaced.

What follows for a deployment

Turn opcache.validate_timestamps off in production and pay for it deliberately. The engine stops stat-ing your files altogether, and in exchange an edit on disk has no effect until the cache entry goes away — which means deploying into a new release directory so the cache keys are new, or reloading the pool. Fixing something by editing a file on a production box stops working. Worth taking when deploys are automated, worth refusing when they are not.

Size opcache.memory_consumption and opcache.max_accelerated_files against the number of PHP files the application actually loads. OPcache does not evict: when either runs out, new scripts stop being cached while everything already stored keeps serving. Which of the two ran out, whether the cache_full flag tells you, and why the restart that should reclaim a wasted segment often does not fire, are a second argument with its own measurements — they used to be five paragraphs here and are now a post of their own.

Then treat cold start as a property of the deployment rather than of the code. The first request after a release pays to compile everything it touches, and the size of that bill was set by the dependency graph long before the release. The second request is faster because the first one did the compiling; which of the two your users get is a deployment question.

Frequently asked

Does OPcache cache the results of my code?
No. It caches compiled opcodes, not the values your code produces. Two requests hitting the same cached script still execute every opcode.
Why does a deploy make the first requests slow?
New file paths and new mtimes invalidate the cached entries, so the next request to each file pays for lexing and compilation again.
Where does the JIT fit into this?
After the VM, and only for code it can profitably compile to machine code. What it reaches and what it leaves alone is the subject of "OPcache, the JIT, and where time actually goes".
Share

Written by

Alden Pike

Alden Pike writes about PHP internals, performance, architecture and production behavior — the layer beneath the frameworks. Measurements over assumptions, trade-offs over universal rules.

More about the author

Related posts

Arrow keys to move, Enter to open.