lisachenko/z-engine

Write PHP extensions in pure PHP: direct FFI access to the Zend Engine internals

Maintainers

Package info

github.com/lisachenko/z-engine

pkg:composer/lisachenko/z-engine

Transparency log

Fund package maintenance!

lisachenko

Statistics

Installs: 23 870

Dependents: 5

Suggesters: 2

Stars: 456

Open Issues: 30

0.9.1 2021-06-26 16:38 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.

CI GitHub release PHP Version License PHPStan

⚠️ 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 $this and 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_object handler first — the other hooks live in memory that it allocates. Internal classes can't receive a create_object handler.

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:

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.