ZDebug is an Xdebug-compatible step debugger with no C extension. Your IDE attaches over DBGp β Xdebug's own protocol β while pure PHP code drives the Zend VM through FFI, courtesy of z-engine. Set breakpoints, step through code, inspect and edit the stack and variables in PhpStorm or VS Code, with nothing compiled and nothing installed but Composer packages.
β οΈ Experimental. ZDebug pokes live engine memory through z-engine. It is a research vehicle, not a production tool. Pin your PHP version, keep the JIT off, and expect rough edges.
Xdebug is a compiled zend_extension. That is the right design β and also a barrier: you need the matching binary for your exact PHP build, a working extension toolchain, and the willingness to load native code into your interpreter. ZDebug asks a different question: how much of a step debugger can you build in pure PHP? The answer, it turns out, is most of it β because z-engine already hands you the engine's internals as ordinary PHP objects. ZDebug is the debugger that question produces.
your app (compiled WITH extended-statement info)
β each statement emits an EXT_STMT opline
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Zend VM βββΆ EXT_STMT opcode βββΆ ZDebug's user-opcode handler β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β (file, line) matches a breakpoint?
βΌ
the handler BLOCKS, reading DBGp commands
from the IDE socket β the process is suspended
inside the VM, exactly like a debugger should be
β run / step / stack_get / context_get
βΌ
resume β return ZEND_USER_OPCODE_DISPATCH
The engine is debugging itself. z-engine compiles your code with COMPILE_EXTENDED_STMT so every statement carries an EXT_STMT opline, installs a userland handler for that opcode, and hands ZDebug a rich view of each suspended frame (ExecutionData) β stack, arguments, named locals, $this. Two more opcode handlers cover the exits: THROW for first-chance exception breakpoints, RETURN for return breakpoints and return-value debugging. ZDebug turns all of that into a DBGp session your IDE already knows how to talk to. See z-engine's docs/self-debugging.md for the feasibility study this package implements.
composer require --dev lisachenko/zdebug:dev-mainPoint PHP at the bootstrap so the debugger starts before your code compiles, and tell it where your IDE is listening:
ZDEBUG_CLIENT_PORT=9003 \
php -d ffi.enable=1 -d opcache.jit=off \
-d auto_prepend_file=vendor/lisachenko/zdebug/bootstrap/zdebug.php \
app.phpIn PhpStorm: Run β Start Listening for PHP Debug Connections, set a breakpoint, run the command above. In VS Code: install the PHP Debug extension, add a "Listen for Xdebug" configuration, start listening.
Prefer wiring it up in code? Drop the auto_prepend_file and call it yourself, first thing:
require __DIR__ . '/vendor/autoload.php';
ZDebug\Debugger::attach([
'client_host' => '127.0.0.1',
'client_port' => 9003,
'path_filter' => [__DIR__ . '/src'], // only instrument your code
]);
require __DIR__ . '/app.php'; // compiled after attach β debuggableZDebug reads Xdebug's own configuration β so if you already have an Xdebug setup, it just works. Both the xdebug.* ini directives and the XDEBUG_* environment are honored, with Xdebug 3 semantics:
# An existing Xdebug 3 configuration drives ZDebug unchanged:
php -d ffi.enable=1 -d opcache.jit=off \
-d xdebug.mode=debug -d xdebug.client_host=127.0.0.1 -d xdebug.client_port=9003 \
-d auto_prepend_file=vendor/lisachenko/zdebug/bootstrap/zdebug.php \
app.php
# start_with_request=trigger β only debug when the trigger is present, like Xdebug:
XDEBUG_TRIGGER=1 php ... app.phpRecognized Xdebug settings: xdebug.mode / XDEBUG_MODE (step debugging needs debug), xdebug.client_host, xdebug.client_port, xdebug.idekey, xdebug.start_with_request (yes / no / trigger / default), xdebug.trigger_value, xdebug.log, and the XDEBUG_CONFIG, XDEBUG_SESSION, XDEBUG_TRIGGER environment variables.
ZDebug registers itself as a genuine engine module at runtime β the same technique APCu used to stand in for APC. Even though there's no compiled extension, the standard tooling reports it:
extension_loaded('zdebug'); // true
in_array('zdebug', get_loaded_extensions(), true); // truezdebug support => enabled
Version => 0.1.0
Protocol => DBGp (Xdebug-compatible)
IDE debugger => no C extension (z-engine FFI)
Mode => debug
Client host => 127.0.0.1
Client port => 9003
IDE key => zdebug
Debug session => active
The lowercase zdebug is deliberate: it is the extension identifier the engine, php -m and php --ri know it by, and the name the <engine> element of the DBGp <init> packet carries. ZDebug is the project; zdebug is what it answers to in code.
ZDebug's native settings (these take precedence over any Xdebug settings above):
| Variable | Default | Meaning |
|---|---|---|
ZDEBUG_MODE |
debug |
off makes the bootstrap a no-op |
ZDEBUG_CLIENT_HOST |
127.0.0.1 |
Host your IDE listens on |
ZDEBUG_CLIENT_PORT |
9003 |
Port your IDE listens on |
ZDEBUG_IDEKEY |
zdebug |
Session key shown to the IDE |
ZDEBUG_PATH_FILTER |
(all) | :-separated path prefixes to instrument β scope this to your code for speed |
ZDEBUG_CONNECT_TIMEOUT_MS |
200 |
If the IDE is not listening, the app runs undebugged |
ZDEBUG_READ_TIMEOUT_MS |
300000 |
How long a suspended script waits for the next IDE command before deciding the IDE is gone and running on undebugged. 0 waits forever, as Xdebug does |
ZDEBUG_LOG |
(none) | Path to an optional diagnostics log |
Precedence, lowest to highest: built-in defaults β Xdebug ini/env β ZDEBUG_* β an explicit array passed to Debugger::attach([...]).
| Feature | ZDebug | Notes |
|---|---|---|
| Line breakpoints | β | On any code compiled after attach; temporary (-r 1) breakpoints included |
| Conditional breakpoints | β | Condition evaluated in the frame; hit counts via -h / -o (>=, ==, %) |
| Step over / into / out | β | Depth-based resume machine over statement hits. Internal (C) functions emit no EXT_STMT, so stepping into them is impossible β same as Xdebug |
| Stack traces | β | Full getPrevious() walk, with call sites |
Locals, args, $this |
β | Named, from CV slots β no symbol table needed |
| Superglobals | β | From the engine global symbol table |
Variable inspection (property_get) |
β | Expand any node by its fullname ($e->previous->message, $rows[3]['id']), -p paging, -m / property_value for untruncated data, facet visibility |
Editing variables (property_set) |
β | Writes through to the live frame; existing paths only, and a write the engine refuses (readonly, type mismatch) answers success="0" |
| Return-value debugging | β | Xdebug 3.2's breakpoint_include_return_value: one extra stop when a stepped-through function returns, the value in <xdebug:return_value> and under $__RETURN_VALUE |
eval in frame |
β | Read-only: evaluated against the locals of the frame selected by -d |
| Exception breakpoints | β | First-chance, on the throw itself via the THROW opcode β before unwinding, with the throwing frame still readable. Userland throw only: engine-raised errors (TypeError, DivisionByZeroError, β¦) and throws from internal functions execute no THROW opline and stay invisible |
| Call / return breakpoints | β | -t call on the function's first statement, -t return on its RETURN opline; -m takes fn, Class::fn or Class->fn |
source, stack_depth, typemap_get, breakpoint_update, detach |
β | source reads only within ZDEBUG_PATH_FILTER β a DBGp socket is not a filesystem. detach puts the compiler options back and lets the script finish undebugged |
Async pause (break) |
β | z-engine wraps the VM interrupt this needs, but the DBGp break command is not wired up; supports_async is advertised as 0 |
stdout / stderr redirection |
β | Answered success="0" rather than refused β read the debuggee's output where it already goes |
| Return by reference, generator returns | β | RETURN_BY_REF and GENERATOR_RETURN are different opcodes, so return breakpoints and return values do not fire for them. Statements inside a generator step normally |
| Attach to already-running code | β | Only code compiled after attach is steppable |
| Opcache-cached scripts | β | Invisible unless the cache is cold |
| Profiling / tracing / coverage | β | The engine's observer API can't be enabled from userland (z-engine #106) |
Engine version: the <engine> element of the <init> packet reports the name zdebug and an Xdebug protocol generation (currently 3.2.0) as its version. IDEs read that number as a capability level β PhpStorm gates return-value debugging on >= 3.2 and decides from it alone β so it says which generation of Xdebug's protocol ZDebug speaks, not which release of ZDebug you are running. That release is what php -m, phpinfo() and php --ri zdebug show. feature_get still answers per feature and never claims support the dispatcher cannot deliver.
Performance: every statement in an instrumented file crosses an FFI trampoline. The observation decision is memoized once per op_array, so code outside the filter costs one cached lookup β but scope ZDEBUG_PATH_FILTER to the code you actually want to step through and leave the rest at full speed.
- PHP 8.4 or 8.5 (supported in parallel), NTS, linux-x64 or macOS x64/arm64 (platforms z-engine ships definitions for); Composer resolves the matching z-engine line per minor
ffi.enable=1andopcache.jit=off(the JIT rewrites the executor internals the hook plugs into)- Your app's code must load after ZDebug attaches β
auto_prepend_fileguarantees this
The debugger is complete enough to use as one: an IDE attaches, sets line, conditional, call, return and exception breakpoints, steps over/into/out, walks the stack, reads and writes variables, evaluates expressions, fetches source and detaches β all of it proven end-to-end by a test suite that plays a fake IDE against a real spawned child process, on PHP 8.4 and 8.5 across linux-x64 and macOS (x64 + arm64).
What is left is a short list, and most of it is the engine's doing rather than unfinished plumbing:
- Async pause. The DBGp
breakcommand, on top of z-engine's VM interrupt hook β the one missing command of the core set. - Stepping across
yield/ fiber boundaries. Suspension rewrites theprev_execute_datatopology mid-flight; the depth machine has not been proven against it. - The hard limits in the table above β compile-order coverage, opcache, JIT off, no profiling or tracing β which no amount of work on this side removes.
- Derick Rethans and the Xdebug project for the DBGp protocol every PHP IDE speaks.
- z-engine β the FFI bridge to the Zend Engine that makes all of this possible β and its
docs/self-debugging.mdresearch.
MIT β see LICENSE.