lisachenko / z-engine
Write PHP extensions in pure PHP: direct FFI access to the Zend Engine internals
Fund package maintenance!
Requires
- php: 8.0.*
- ext-ffi: *
Requires (Dev)
- phpunit/phpunit: ^8.5.15 || ^9.0.0
This package is auto-updated.
Last update: 2026-08-06 09:19:37 UTC
README
⚡ Z-Engine
Write PHP extensions in pure PHP.
Z-Engine reaches straight into the heart of the PHP runtime — the Zend Engine — and hands you its internals as ordinary PHP objects. Overload operators, make classes immutable, register real engine modules, rewrite the AST, redefine methods at runtime. No C, no compiler, no recompiling PHP. Just FFI and a lot of nerve.
⚠️ Experimental — not for production. Z-Engine operates on raw engine memory. Segfaults are a feature of the territory, not a bug in your code. Pin your PHP version, run it behind a debug build while developing, and never ship it in an app until 1.0.0.
Why this is different
Every other "runtime magic" library for PHP stops at the boundary of userland. Z-Engine walks straight through it. Using PHP FFI, it loads the exact C struct definitions of the running engine — zend_class_entry, zval, zend_object_handlers, zend_module_entry — and manipulates them the same way a compiled C extension would. The result is a set of capabilities that simply do not exist anywhere else in pure PHP:
| Capability | What you can do |
|---|---|
| 🧮 Operator overloading | Give your objects real +, -, *, /, **, == semantics via the engine's do_operation and compare handlers |
| 🔒 Custom object handlers | Hook create_object, read/write/unset_property, cast_object, get_property_ptr_ptr — build truly immutable objects, copy-on-write types, proxies |
| 🧩 Runtime engine modules | Register a genuine zend_module_entry at runtime, with persistent globals shared across requests — an extension written entirely in PHP |
| 🌳 Abstract Syntax Tree access | Parse source to the engine's own AST, inspect it, and rewrite it through the zend_ast_process hook |
| 🪞 Reflection on steroids | Make a final class non-final, add interfaces and methods at runtime, redefine method bodies, change a method's declaring class |
| ⚙️ Opcode handlers | Install your own handler for any VM opcode |
How it works
FFI lets PHP load shared libraries, call C functions, and read C structures without a compiler or a third intermediate language. Z-Engine points that power back at PHP itself. It ships generated, version-exact FFI definitions of the engine's structures for each supported PHP version, and a runtime that refuses to boot unless the definitions match your interpreter down to the byte. That byte-exactness is what turns "insanely dangerous" into "dangerous but disciplined."
Requirements & support matrix
- PHP with the FFI extension enabled
- x64, non-thread-safe (NTS) builds
Engine memory layouts change between every PHP minor version, so each PHP minor has its own generated definitions and its own branch.
| PHP | OS / Arch / TS | Branch | Status |
|---|---|---|---|
| 8.5 | linux-x64-nts | master |
🚧 in progress |
| 8.4 | linux-x64-nts | 8.4 |
✅ supported |
| 8.0 | linux-x64-nts | 8.0 |
🧊 frozen (legacy) |
| macOS / Windows / ZTS | — | — | 📋 tracked in issues |
Version matching is not optional. Running Z-Engine against a PHP minor it was not built for corrupts memory.
Core::init()enforces the match and aborts with a clear message rather than letting you crash.
Memory safety & long-running PHP
Every value wrapper follows an explicit ownership model: owning constructors take their own
engine reference and release it deterministically (release()/destruction), fromCData()
factories stay borrowed, and all releases go through the engine's own primitives
(zval_ptr_dtor/rc_dtor_func) — never through the FFI allocator. Engine hooks have a full
lifecycle (install()/uninstall()/reinstall()) backed by a registry, and Core::shutdown()
(registered automatically) restores every hooked engine pointer before the engine could ever
call a freed trampoline — which is what makes worker loops and FPM + opcache preload viable.
Notable behaviour changes compared to older releases:
new StringEntry()/new ObjectEntry()/new ResourceEntry()addref and keep their target alive for the wrapper lifetime;ClosureEntry::setThis()releases the old bound$thisand references the new one (no more "object must outlive the closure").Compiler::parseString()trees free themselves when the last node wrapper is collected.AbstractHook::__destruct()no longer force-restores pointers at arbitrary GC moments.
See docs/long-running.md for the ownership tables, the hook lifecycle, runtime models (worker vs FPM), and the short list of immortal-by-design allocations.
Installation
composer require lisachenko/z-engine
Initialize the library once, early in your bootstrap:
use ZEngine\Core; require __DIR__ . '/vendor/autoload.php'; Core::init();
For web (non-CLI) usage, enable FFI preloading by calling Core::preload() from the script named in your opcache.preload — this loads the engine definitions once at server start instead of per request.
Hello, impossible
<?php declare(strict_types=1); use ZEngine\Core; use ZEngine\Reflection\ReflectionClass; require __DIR__ . '/vendor/autoload.php'; Core::init(); final class Sealed {} $reflection = new ReflectionClass(Sealed::class); $reflection->setFinal(false); eval('class Extended extends Sealed {}'); // ...it just works.
A tour of the API
Reflection, extended
ZEngine\Reflection\ReflectionClass and ReflectionMethod extend the native reflection classes with write access to the engine:
$class = new ReflectionClass(Sealed::class); $class->setFinal(false); // un-final a class $class->setAbstract(true); // make it abstract $class->addInterfaces(Countable::class); // graft on an interface at runtime $class->addMethod('count', fn() => 42); // add a method from a closure $method = new ReflectionMethod(Service::class, 'handle'); $method->setPublic(); $method->redefine(fn() => 'patched'); // swap the method body
Generated functions — no FFI trampoline
Turn a closure into a genuine engine function or method. Unlike a closure installed into an engine handler field (which ext/ffi calls back into through a slow libffi trampoline), a generated function is published straight into the engine's function table and afterwards dispatches through the normal Zend VM with zero FFI at call time — exactly as fast as any ordinary PHP function:
use ZEngine\Reflection\ReflectionFunction; ReflectionFunction::addFunction('twice', fn (int $x): int => $x * 2); twice(21); // 42 — a real global function, no trampoline $class->addMethod('scale', fn (float $k) => ...); // same, as a method
See docs/memory-model.md for how PHP zvals, FFI trampolines and native C handlers map to memory, and why this path is fast.
Operator overloading
Give your value objects native arithmetic. Implement the extension interfaces and install the handlers with one call:
use ZEngine\ClassExtension\ObjectCreateInterface; use ZEngine\ClassExtension\ObjectCreateTrait; use ZEngine\ClassExtension\ObjectDoOperationInterface; use ZEngine\ClassExtension\ObjectCompareValuesInterface; use ZEngine\ClassExtension\Hook\DoOperationHook; use ZEngine\Reflection\ReflectionClass; class Matrix implements ObjectCreateInterface, ObjectDoOperationInterface, ObjectCompareValuesInterface { use ObjectCreateTrait; public static function __doOperation(DoOperationHook $hook): self { /* ... */ } // public static function __compare(CompareValuesHook $hook): int { ... } } (new ReflectionClass(Matrix::class))->installExtensionHandlers(); $c = new Matrix([10, 20, 30]) + new Matrix([1, 2, 3]); // Matrix([11, 22, 33]) $c *= 2; // → Matrix([22, 44, 66])
No access to the class source (e.g. it lives in vendor/)? Install the handlers imperatively instead:
$class = new ReflectionClass(Matrix::class); $class->setCreateObjectHandler(Closure::fromCallable([ObjectCreateTrait::class, '__init'])); $class->setWritePropertyHandler(fn ($hook) => /* ... */);
The available object hooks are create_object, cast_object, do_operation, compare, read_property, write_property, has_property, unset_property, get_property_ptr_ptr, get_properties_for, and interface_gets_implemented.
Install the
create_objecthandler first — the other hooks live in memory that it allocates. Internal classes can't receive acreate_objecthandler.
The object store
Look up any live object by its handle — an API PHP itself doesn't expose:
$instance = new stdClass(); $entry = Core::$executor->objectStore[spl_object_id($instance)];
Abstract Syntax Tree
Parse PHP source to the engine's own AST and walk it:
$ast = Core::$compiler->parseString('echo 2 + 2;'); echo $ast->dump();
You can also install a zend_ast_process hook to rewrite the AST of every file as it compiles.
OpCache binary files
Read the binary files opcache writes for opcache.file_cache, patch the compiled script through the framework wrappers, and write a valid binary back — so the engine loads and executes your patched code on the next request:
use ZEngine\OpCache\BinaryCacheFile; $file = BinaryCacheFile::compile(__DIR__ . '/Service.php', $cacheDir); $reflection = $file->getReflection(); // ReflectionExtension-shaped handle over the cached script // ... mutate literals, opcodes, flags through the usual wrappers ... $file->refresh(); // rewrite the binary + invalidate the source
The payload is re-serialized from the mutated graph (not just byte-poked), so size-changing edits are written correctly. See docs/opcache-binary.md for the format, build-matching rules and current limits — this is the foundation for AOP, transpiling and source-code protection on top of the file cache.
Extensions written in PHP
Register a real engine module at runtime, complete with persistent globals shared across requests:
use ZEngine\EngineExtension\AbstractModule; final class Counter extends AbstractModule { protected static function globalType(): ?string { return 'unsigned int[10]'; } } $module = new Counter('counter'); $module->register(); $module->startup(); $globals = $module->getGlobals(); // FFI-backed, survives across requests
See it in action
These libraries are built entirely on Z-Engine and make good, real-world reading:
- lisachenko/immutable-object — mark a class immutable with a single interface; property writes outside the constructor throw
- lisachenko/native-php-matrix — a
Matrixtype with fully overloaded arithmetic operators - lisachenko/userland-php-generics — reified generics:
Box::of('int')monomorphizes a template into a real class entry whoseintthe engine enforces, and measures what that costs
Contributing
Z-Engine has a couple of unusual rules — most importantly, match your PHP version to the branch and develop against a debug build. See CONTRIBUTING.md and AGENTS.md (the full contract for humans and automated tools). Engine definitions are generated from the PHP source by tools/generator/ and never hand-edited.
composer test # safe suite composer test:internal # destructive tests, on a debug PHP build composer phpstan # static analysis at level max composer cs:check # coding standards
License
Released under the MIT License.