Zend Engine Architecture: The Pipeline Shape
Recognize the overall shape of the Zend Engine's compile-and-execute pipeline -- lexing, parsing to an AST, compiling to opcodes, and running those opcodes on the Zend VM -- and know where extensions hook into that pipeline, without re-deriving the full mechanics already covered in depth elsewhere in this course.
Every PHP script you've ever run passed through the same four-stage pipeline before a single line of your code actually executed: lexing, parsing, compiling, and executing. This lesson is a short, map-level pass over that pipeline and over how extensions attach themselves to it -- just enough to place terms like 'opcode', 'AST', and 'module init hook' correctly relative to each other. For the full mechanics -- the actual lexer/parser implementation, opcode formats, the Zend VM's execution loop in detail, and a complete tour of the extension API -- see the 'PHP Runtime and the Zend Engine' lesson in this course, which this lesson deliberately does not repeat.
From source text to a running program: the compile pipeline
The Zend Engine turns a .php file into a running program in four ordered stages. First, the lexer (built with re2c) scans the raw source text and breaks it into tokens -- keywords, variable names, operators, string literals -- discarding whitespace and comments along the way. Second, the parser (built with Bison, a yacc-style parser generator) consumes that token stream and builds an abstract syntax tree (AST), a nested structure that represents the script's actual grammatical shape: which expressions nest inside which statements, which statements belong to which function bodies.
Third, the compiler walks that AST and emits opcodes -- Zend's own low-level, bytecode-like instruction set, where each opcode is a simple operation like 'add two values', 'call a function', or 'jump if false'. Fourth, the Zend virtual machine executes those opcodes one at a time in an interpreter loop, maintaining an execution stack of frames much like any other bytecode VM. This four-stage shape -- lex, parse, compile, execute -- is the same shape used by many interpreted languages, and it's the reason a tool like OPcache can cache just the third stage's output (the opcodes) and skip stages one through three entirely on a repeat request.
• Lex: source text -> token stream (re2c-generated lexer). • Parse: token stream -> abstract syntax tree (Bison-generated parser). • Compile: AST -> opcodes (Zend's internal bytecode-like instruction set). • Execute: opcodes run one at a time on the Zend virtual machine's interpreter loop.
Where extensions hook in
A PHP extension (whether a bundled one like json or a third-party C extension) doesn't rewrite this pipeline -- it registers itself with the engine through a small set of lifecycle hooks and then adds new functions, classes, or opcode handlers that the pipeline calls into at the right moment. MINIT (module init) runs once when PHP itself starts up (or once per worker process under PHP-FPM) and is where an extension registers its functions, classes, and INI settings. RINIT (request init) runs at the start of every single request and is where per-request state gets reset. Their counterparts, MSHUTDOWN and RSHUTDOWN, run at the matching teardown points.
This hook structure is exactly why an extension's state can leak across requests if it's careless: anything set up in MINIT persists for the life of the worker process across many requests, while anything meant to be request-scoped belongs in RINIT/RSHUTDOWN instead. The full extension API -- writing an extension in C, the zend_function_entry table, registering classes and constants, and how OPcache interacts with extension-registered opcode handlers -- is covered in depth in the 'PHP Runtime and the Zend Engine' lesson; this section is only the map of where that machinery sits relative to the compile pipeline above.
• MINIT (module init): runs once per worker process startup -- registers functions, classes, INI settings. • RINIT (request init): runs at the start of every request -- resets per-request state. • RSHUTDOWN / MSHUTDOWN: the matching per-request and per-process teardown hooks. • State set in MINIT lives for the whole worker process; state that should be per-request belongs in RINIT/RSHUTDOWN.
Code Examples
A single line of PHP passes through all four pipeline stages before it produces any effect: the lexer never sees 'an assignment', only a flat token stream; the parser is what first recognizes the nested multiply-then-assign structure; the compiler flattens that tree back into a short, linear sequence of opcodes; and only the VM actually performs the multiplication and the assignment.
ZEND_MUL / ZEND_ASSIGN are the opcodes the Zend VM actually executes.
Source: $total = $price * $quantity;
Lex: [T_VARIABLE $total] [=] [T_VARIABLE $price] [*] [T_VARIABLE $quantity] [;]
Parse (AST): ASSIGN
|-- VAR: total
`-- MUL
|-- VAR: price
`-- VAR: quantity
Compile: ZEND_MUL T_TMP $price, $quantity
ZEND_ASSIGN $total, T_TMP
Execute: Zend VM runs ZEND_MUL, then ZEND_ASSIGN, against the current execution frame.Important Rules
The pipeline is always lex -> parse (to an AST) -> compile (to opcodes) -> execute (on the Zend VM), in that order, for every request that isn't served from an opcode cache.
MINIT/MSHUTDOWN are process lifetime; RINIT/RSHUTDOWN are request lifetime -- mixing these up is a common source of state leaking across requests in a poorly written extension.
For the full mechanics of any of these stages, or the complete extension API, see the 'PHP Runtime and the Zend Engine' lesson rather than trying to derive it from this overview alone.
[multiple-choice] Which ordering correctly describes the Zend Engine's compile pipeline for a PHP script? A. Execute opcodes, then compile them, then parse the source, then lex it B. Lex the source into tokens, parse the tokens into an AST, compile the AST into opcodes, execute the opcodes on the Zend VM C. Parse the source directly into opcodes, then lex the opcodes, then execute D. Compile the source directly to machine code with no intermediate representation
B. Lex the source into tokens, parse the tokens into an AST, compile the AST into opcodes, execute the opcodes on the Zend VM — The pipeline runs in a fixed order: lexing turns source text into tokens, parsing turns tokens into an abstract syntax tree, compiling turns that tree into opcodes, and the Zend virtual machine executes those opcodes. This is the same four-stage shape that lets a tool like OPcache skip straight to the execute stage by caching the compiled opcodes.
[multiple-choice] An extension stores a value during MINIT that it expects to be fresh and empty on every incoming request, but it turns out to still hold a value from a previous request. What is the most likely cause? A. MINIT state is per-request, so this should never happen B. The value was set up in MINIT (process lifetime, runs once per worker) when it should have been initialized in RINIT (request lifetime, runs at the start of every request) C. The Zend VM does not support extensions storing any state D. OPcache deleted the value between requests
B. The value was set up in MINIT (process lifetime, runs once per worker) when it should have been initialized in RINIT (request lifetime, runs at the start of every request) — MINIT runs once per worker process startup, so anything set there persists across every request that worker subsequently handles. Anything that needs to start fresh on each request must be initialized in RINIT instead, which runs at the start of every single request.