swoole-bundle/z-engine
Integrates Z-Engine with Swoole Bundle to deliver coroutine-based, high-performance PHP execution for Laravel and other PHP apps. Simplifies async runtime setup, worker configuration, and Swoole-powered request handling for scalable services.
z-engine is an FFI bridge into Zend Engine internals — execution data, opcodes, zvals, hash tables, and engine hooks. It is not an application framework; it gives you read/write access to what PHP is doing at the VM level. This fork lives under the symfony-swoole org (upstream lineage is lisachenko/z-engine), so expect Swoole-oriented tweaks rather than a large divergence.
Verify the hard constraints before writing any code:
composer show swoole-bundle/z-engine # check php + ext-ffi requirements
php -v
php -r 'var_dump(extension_loaded("ffi"));' # must be bool(true)
php -i | grep -i '^ffi.enable' # CLI needs 1 or "preload"
Install as a dev dependency and pin it — 0 dependents and a 4-star fork means you do not want floating versions:
composer require --dev swoole-bundle/z-engine
Look at these first, in order:
src/Core.php — the FFI bootstrap that loads the C definitions. This is the class you call once, early, before anything else touches the engine.src/System/ExecutionData.php — current function, opcode/opline, $this, arguments.src/System/Hook/ — the hook attachment API (HookInternals and friends in the upstream package; confirm exact names in this fork).src/Type/ and src/Reflection/ — zval/HashTable/ReflectionValue wrappers.tests/ and the diff against upstream lisachenko/z-engine — that diff tells you what the Swoole fork actually changed.Bootstrap once, in a CLI-only or dev entry point:
use ZEngine\Core;
require __DIR__ . '/vendor/autoload.php';
Core::init(); // loads FFI cdefs; do not call per request
First use case: enrich an exception with VM context. Wrap your dev error handler, instantiate ExecutionData, and log the current function name and opcode line. That is a 20-line change, it is read-only, and it proves the FFI setup works without risking a segfault.
Gate it behind an env flag and a class_exists guard. Every call site should degrade gracefully when FFI is unavailable:
if (!class_exists(Core::class)) {
return; // no-op in prod images without ext-ffi
}
Hook in WorkerStart, flush in WorkerStop. Engine hooks are process-global and effectively permanent. Attaching them per request in a Swoole worker is unnecessary and expensive; attach once per worker, buffer records in a ring buffer, and write them out on shutdown.
$server->on('WorkerStart', function ($server, int $workerId) use ($buffer) {
if (getenv('ZENGINE_TRACE')) {
$buffer->reset();
HookInternals::attachExecuteHook(); // verify exact signature in src/
// callback appends plain arrays to $buffer — no I/O, no coroutines
}
});
$server->on('WorkerStop', fn() => file_put_contents('/tmp/zengine.log', $buffer->dump()));
Keep hook callbacks synchronous and non-yielding. A callback that calls Coroutine::sleep, does Redis I/O, or suspends the coroutine will re-enter the VM hook while the engine is mid-opcode. Buffer to memory; export later.
Build a thin adapter. Never let ExecutionData, zval wrappers, or FFI structs leak into your domain code. Expose plain arrays and scalar DTOs so the rest of the app is testable without FFI:
final class EngineFrame
{
public static function describeCurrent(): array
{
$data = new ExecutionData();
return [
'function' => $data->getFunctionName(), // confirm accessor names
'opline' => $data->getCurrentOpLine(),
];
}
}
Use it as a dev-time tracing tool, not a prod feature. The realistic daily use case is: "this Swoole request is slow / this function returns garbage — where in the VM is that happening?" Point it at one function or one file, collect frames, then turn the hook off. That is a far better ROI than trying to build a monitoring product around it.
Opcode analysis from the CLI. Running the package under php CLI against a single file to inspect compiled opcodes is the safest mode: short-lived process, no Swoole, no coroutine interleaving, crash costs you nothing. Use it to answer "why is this hot loop shaped this way" before reaching for Xdebug.
Testing with separate processes. Hooks are installed for the lifetime of the PHP process. In PHPUnit, annotate the relevant tests with @runInSeparateProcess and @preserveGlobalState disabled; otherwise the first test poisons every subsequent one in the same worker.
Swoole integration tip: instantiate ExecutionData and inspect it within the same synchronous call stack you're debugging. Do not store zval wrappers on an object that survives a coroutine yield or a request boundary — that is how you get a dangling pointer.
ffi.enable is the first failure you'll hit. It defaults to preload for FPM/web SAPIs; a class-not-found or "FFI API is restricted" error usually means the ini setting, not a missing package. Set ffi.enable=1 for CLI, and prefer CLI-only usage.
Zend struct layout is version-specific. Zend changed zend_execute_data, zend_op, and opcode numbering between 7.4, 8.0, and 8.1+. A fork targeting one minor version will segfault or read garbage on another. Check the composer.json PHP constraint and the CI matrix, and lock your PHP runtime to match.
Hooks are global and irreversible. There is no safe "detach" for most internals hooks. Once attached, every execution in that process pays the cost and shares the risk. Design for one worker role or one CLI tool per process.
JIT and opcache will confuse you. JIT can bypass opcode-level assumptions and inline away the frames you're trying to observe. Disable JIT (opcache.jit=off) when hooking, and be aware that preloading/opcache may change what ExecutionData reports for cached scripts.
Lifetime bugs manifest as segfaults, not exceptions. Holding a ReflectionValue, zval wrapper, or HashTable view past the function call or request that produced it leads to crashes with no stack trace. Rule: use engine objects only inside the immediate scope that created them.
Debugging crashes: set USE_ZEND_ALLOC=0 and run under gdb --args php -d ffi.enable=1 script.php, or enable core dumps. ZEND_DONT_UNLOAD_MODULES=1 helps get readable backtraces. Treat any segfault as a lifetime/version bug, not a package bug, until proven otherwise.
Pin to a commit hash, not a tag. Last release is dated 2026-08-28 but dependents = 0 and stars = 4; there is no ecosystem pressure to preserve BC. Record the exact SHA in composer.json and re-review the diff on upgrade.
Missing APIs are a real possibility. This is a fork of a package that was itself experimental. If a class or method referenced in upstream docs doesn't exist here, check src/ — do not assume the README is accurate for this fork.
Extension points: Core::init() options (for FFI scope/flags) and the HookInternals family are the intended seams. Subclassing ExecutionData to add formatters is usually safe because it wraps rather than mutates the engine. Anything that writes into zvals or rewrites opcodes is not safe for day-to-day use — keep to read paths.
Reach for a simpler tool first. If you need tracing, Xdebug or php-spx will get you 90% there without FFI risk. If you need Swoole-aware profiling, Swoole's own hooks or OpenTelemetry instrumentation are more maintainable. Reserve z-engine for the cases those tools genuinely cannot see — raw opcode inspection, function-call interception at the VM level, and zval inspection.
How can I help you explore Laravel packages today?