# 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.

- Published: 2026-07-02
- Tags: zend-vm, opcache, jit, php-fpm
- Source: https://elephantphp.com/blog/what-happens-when-php-runs-your-code/
- Language: en-US
- Author: Alden Pike

---
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](/blog/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 — which under the default `auto_globals_jit`
materialize on first access, not 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](/blog/opcache-jit-and-where-time-actually-goes/) 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](/blog/php-arrays-are-not-arrays/) costs rather than what the
instruction count suggests. Two statements show the shape:

```php
<?php

// total.php

declare(strict_types=1);

$total = (int) $argv[1] + 5;
echo $total, PHP_EOL;
```

```bash
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:

```text
$_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:

```text
$_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.

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 have not measured that difference
here.

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](/blog/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 the key slots run out, `cache_full` goes true and new scripts stop being
cached, while everything already stored keeps serving — a cliff rather than a
slow decline.

Running out of memory refuses new scripts the same way, but `cache_full` does
not reliably report it. Loading 300 files of roughly 38 KB each into a 16 MB
segment, 3,907 key slots free throughout, cached 88 of them and refused 212 —
with 117,912 bytes still free and the flag false. A true reading means the cache
is full; a false one does not mean it is not. That asymmetry is the reason to
watch `free_memory` on this path rather than the flag.

Filling the cache is also what arms its restart. Once `cache_full` is true,
pushing wasted memory past `opcache.max_wasted_percentage` schedules one; below
that threshold, or before the cache has filled, nothing is scheduled — with
3,907 slots free in a 16 MB segment I reached 10.4% wasted against a 5%
threshold and nothing was queued. Neither condition has to come first. In that
same 16 MB segment cut to 223 key slots, invalidating 222 cached files to 5.76%
wasted scheduled nothing; a later load of all 400 exhausted the slots and
scheduled the restart then, having invalidated nothing at all. `opcache_reset()`
schedules one regardless of both.

The two findings interlock badly. The restart gate consults that same unreliable
flag, so the same 16 MB segment with all 300 files invalidated sat at 85.68%
wasted, `cache_full` false, no restart scheduled, and `oom_restarts`,
`hash_restarts` and `manual_restarts` all zero.

So read three things rather than one. Filling a 223-slot cache with 400 files on
PHP 8.5.9 CLI, default 128 MB segment, turned `cache_full` true and pinned
`num_cached_keys` at 223 of 223 with 119 MB still free — the slots ran out, not
the memory — and the first file stayed cached while the last was never cached.
On the memory path those two look healthy instead and only `free_memory` moves.
Read all three, and treat the restart counters as the answer to a different
question.

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.
