A live look at how PHP runs a script: the compiled opcodes, the source, variables, the function/class structs, and the call stack — stepped one line at a time.
Linux or macOS.
| Need | For | Get it |
|---|---|---|
php CLI |
all views | any PHP 8.x |
phpdbg |
the live watch |
macOS brew install php; Debian/Ubuntu apt install php-cli php-phpdbg; or a php-src build |
| bash 4+ | the script | macOS ships 3.2 → brew install bash |
opcache (for phpeek opcodes) and ext/reflection are normally already present.
phpeek finds php/phpdbg on PATH, or in a php-src build tree under the current
directory. Override with --php=/path / --phpdbg=/path or $PHPEEK_PHP / $PHPEEK_PHPDBG.
phpeek check # report each dependency and how to fix what's missingphpeek # dashboard — lists examples/ to pick from
phpeek script.php # dashboard on a scriptThe menu:
o opcodes the compiled program (every opcode)
f functions the function table after loading
c classes user classes and their methods
w watch step execution live (opcodes, vars, structs, call stack)
x run execute it, show the output
e engine loaded extensions + counts
s script switch script — lists examples/, pick by number or type a path
q quit
Each panel also has a direct form: phpeek opcodes|funcs|classes|watch|run <script>, phpeek engine.
watch drives a live phpdbg session. Each space executes one more line; every
panel is re-queried from the running process and redrawn.
phpeek · LIVE loop.php line 3 in combine step 3
OP_ARRAY combine ops 7 args 2 CVs 2 tmps 1 lines 6-8
OPCODES ▶ = line running now SOURCE
L0006 0000 CV0($x) = RECV 1 1 <?php
L0006 0001 CV1($y) = RECV 2 2 function add($a, $b) {
▶ L0007 0002 T2 = ADD CV0($x) CV1($y) ▶ 3 return $a + $b;
▶ L0007 0003 RETURN T2 4 }
VARIABLES 6 class Accumulator {
$a integer = 0 9 public function push(int $n) {
$b integer = 1 14 $acc = new Accumulator();
MEMORY emalloc'd, in use 502,200 B 16 $acc->push($i);
entered add() +480 B (call frame on the VM stack)
ENGINE TABLES 2 function(s), 1 class(es) ([t] to expand)
CALL STACK {main} is #0; each call stacks upward
frame #2: add(a=0, b=1)
frame #1: Accumulator->push(n=1)
frame #0: {main}
[space] step [r] restart [s] script [i] opline+cap [<-/->] move [t] tables [q] quit
The dashboard fits your terminal. OPCODES/SOURCE stays at the top as a window
centered on the current line (... marks more above/below); everything below it — the
status panels and the key bar — is one block pinned to the bottom of the screen, so the
layout stays put as you step. Engine tables collapse to a one-line summary ([t] expands).
The OP_ARRAY band above OPCODES names the struct those oplines belong to — one
op_array per function/method/{main} — with its own counts (ops, args, CVs,
tmps) and source span, read straight from phpdbg's header. Step into a call and the
band swaps to the callee's op_array, making the compilation-unit boundary visible.
Press i to open the opline view — it shows both the ZEND_OP panel and the
zend_execute_data panel together. A source line usually compiles to several oplines
(and phpeek steps by line, so they all run in one space), so it opens on the first and
the arrow keys ←/→ walk through them (op k/N); i again closes both:
ZEND_OP line 20 -> 4 oplines; showing op 2/4 ([<-/->] to walk — all 4 run in one [space])
opcode SEND_VAR_EX
op1 [CV ] CV1($i)
op2 [CONST] 1
result (unused)
lineno 20 (op #0012 in the op_array)
handler ZEND_SEND_VAR_EX_SPEC_CV_CONST_HANDLER (SPEC-naming convention)
As you walk with the arrows, the zend_execute_data panel's +0x00 opline field
follows the cursor, so you can watch execute_data->opline advance through the op_array
one instruction at a time — exactly as it does during real execution.
Press d to overlay the current frame's zend_execute_data:
ZEND_EXECUTE_DATA current frame — 80-byte header, then CV slots
+0x00 opline -> op #0000 CV0($n) = RECV 1
+0x18 func -> Accumulator->push
+0x20 This (zval,16B) -> Accumulator#1 [u2 = num_args = 1]
+0x30 prev_execute_data -> {main}
+0x50 CV slots (each a 16B zval, contiguous after the header):
+0x50 $n = integer = 1
- Op_array | Opcodes | Source — the
OP_ARRAYband names the compiled unit these oplines live in (ops/args/CVs/tmps + line span, from phpdbg's header); below it the current function's opcodes sit beside the script,▶on the line about to run. - opline + execute_data (
iopens both;←/→walk;icloses) — the line's oplines one at a time, each broken into itszend_opfields:opcode,op1/op2/resultwith their operand types (CONST/TMP/VAR/CV),lineno, and the SPEC-specialized handler name. A source line compiles to several oplines (all run in one line-step), so the panel showsop k/Nand the arrow keys move the cursor; the paired execute_data panel'soplinefield tracks it, and the OPCODES panel highlights that exact opline (yellow bar, distinct from the green line-band) so you can see which op the cursor is on. Fields are decoded from the opcode text (real); the handler follows the SPEC naming convention (which folds TMP/VAR/CV and adds axes like RETVAL_USED, so the real symbol may differ). Lets you watch how each dump row is a struct — and how operand types pick the specialized C handler. - Variables — the compiled variables (CV slots) with their current values.
- Memory — emalloc'd bytes in use (
memory_get_usage()), and the change this step attributed to what caused it: a variable assigned (+N Bon the heap, or±0for a scalar stored inline), or a call entered / returned (±480 Bas its frame is pushed or popped on the VM stack). It's all heap —memory_get_usage()never counts the C stack. - Engine tables (
tto toggle) — each user function/class's structure: signatures,ce->parent,properties_info,function_table, read live via reflection. Collapsed to a summary line by default since it rarely changes while stepping. - execute_data (opened together with the opline panel by
i;dtoggles it alone) — the current frame'szend_execute_dataoverlaid on its real layout:opline,func,This/num_args,prev_execute_data, and the CV slots contiguous after the 80-byte header (offsets verified on this build). It's a reconstruction from data phpeek already has — the field values are semantic, not raw addresses — so you can watch each field change as you step without a debugger attached. With the opline panel (i) also open, the+0x00 oplinefield follows the[i]cursor (marked(follows [i])) — so you can watchexecute_data->oplinewalk the op_array one instruction at a time, exactly as it does during real execution. - Call stack — frames pushing/popping as you step into and out of calls, with live
argument values.
{main}is#0at the bottom and the number grows upward into each nested call.
Examples are in examples/: simple.php, loop.php, classes.php.
- Single file — only the main script is breakpointed;
require/included files run but aren't stepped.watchshows a note when it detects them. - phpdbg adds opcodes — under phpdbg the compiler emits
EXT_STMT/EXT_FCALL_BEGIN/EXT_FCALL_ENDmarkers a normal compile does not.phpeek opcodesuses a plain compile, so compare the two to tell real opcodes from debug hooks.watchhidesNOPs (they do nothing, and phpdbg keeps a leading one from an early-bound class/function declaration that a normal compile discards). opcodesshows the pre-optimizer form (ADDbefore it's folded).watchsteps by line, so▶marks the whole opcode band for the current line.- No refcounts — phpdbg's
info varscan abort on an undefined variable, so values come from anisset-guardedevand refcounts are skipped. - Steps forward only (phpdbg can't reverse). Press
rto restart; at the end of the run the footer switches to a✓ finishedbar andspaceloops back to the top. - Tested on Linux; the macOS path is the same code but not yet smoke-tested on macOS.
