Release 8.4.1
⚡ Z-Engine 8.4.1 — "Everywhere PHP runs"
83 commits. 161 files. ~36,000 lines. Six days.
8.4.0made Z-Engine powerful.8.4.1makes it portable — macOS, Windows, and thread-safe builds all land at once, engine structs finally autocomplete in your IDE, and there is nothing left to initialize. 🌍
composer require lisachenko/z-engine:^8.4🌍 The headline: 1 platform → 8
8.4.0 shipped exactly one set of generated engine definitions: linux-x64-nts. Everything else was a row in the README that said "tracked in issues."
That row is gone. 🎉
| Target | 8.4.0 | 8.4.1 |
|---|---|---|
linux-x64-nts |
✅ | ✅ |
linux-x64-zts |
— | ✅ |
darwin-x64-nts / darwin-x64-zts |
— | ✅ |
darwin-arm64-nts / darwin-arm64-zts |
— | ✅ |
windows-x64-nts / windows-x64-zts |
— | ✅ |
macOS on Apple Silicon. Windows. Thread-safe builds. All generated natively, all byte-exact, all tested in CI — including a macOS opcache non-skip gate that fails rather than quietly passing (#58, #59).
Getting there meant teaching the generator to run natively instead of only in Docker, synthesize bodyless typedefs for opaque libc types, extend truncated declaration slices to the semicolon, read MSVC atomic flags, define ZEND_DEBUG explicitly on Windows, and LF-normalize the Windows artifacts so they survive a checkout. Unglamorous, load-bearing work. 🔧
🧵 ZTS is real support, not a footnote
Thread-safe builds don't just get definitions — they get the plumbing:
EG/CGresolved through the TSRM on ZTS builds, instead of the NTS globals- Thread-safe module globals registration and access for extensions written in PHP
- Release and debug CI legs run on ZTS
⚠️ Known limit: the opcache file-cache relocator is not yet supported on ZTS (#118) or Windows (#119) — it refuses those payloads rather than corrupting them. Everything else in the library works across all eight targets.
🔌 Zero-config boot
Core::init() is no longer something you have to remember.
require __DIR__ . '/vendor/autoload.php'; // ...that's it.
final class Sealed {}
(new ReflectionClass(Sealed::class))->setFinal(false);The engine bridge boots from Composer's autoloader — including the opcache.preload stage, which the bootstrap now recognises and serves by publishing the definitions for the life of the server rather than for the preload request alone. Point opcache.preload at a script that only requires the autoloader and the per-request cost is gone. ⚡
It degrades honestly, too. On a host that can't run the engine — no ext-ffi, ffi.enable=0, an unsupported PHP minor or platform — autoloading stays silent and leaves Core uninitialized, so static analysis and test suites still load the package instead of exploding. Ask Core::isInitialized() for the state, or call Core::init() to get the reason. Set ZENGINE_AUTOBOOT=0 to opt out entirely.
Core::init() remains public, idempotent and re-invocable — for booting explicitly at a chosen point, or re-booting after Core::shutdown() inside a live worker. 🔁
🧠 Engine structs that autocomplete
The single biggest developer-experience change in this release.
Every C value in PHP FFI — a zend_string*, a zval*, an int — is the same opaque final class FFI\CData. Which means engine struct access has always been invisible to PHPStan and your IDE. Type a -> and you got nothing.
8.4.1 generates analysis-only stub classes for every engine struct, where each C field is a real typed public property:
namespace ZEngine\Generated;
final class HashTable
{
public zend_refcounted_h $gc;
public HashTable_u $u;
public int $nTableSize;
// ...
}The whole Core, Reflection and Type layer was migrated onto them, and Core's type entry points accept them directly. PHPStan resolves engine field access statically at level max; your IDE autocompletes it. The runtime values are still plain CData — the stubs live outside the PSR-4 roots, are never loaded, never instantiated, never instanceof'd. Zero runtime cost, full static visibility. 🔍
📣 …and a proposal to fix it upstream
The stub trick works, but it's a workaround for a real gap in PHP itself. So it caused a php-src feature request php/php-src#23229 proposing an opt-in, per-scope FFI class map: declare that C type X should be minted as your final class \My\X extends FFI\CData, so FFI::new(), FFI::cast(), field reads and function returns all produce typed instances — and instanceof finally works.
🐛 Debugger-grade introspection
The primitives that made ZDebug — an Xdebug-compatible DBGp step debugger with no C extension — possible:
Core::$compiler->setOptions(Core::$compiler->getOptions() | Compiler::COMPILE_EXTENDED_STMT);
OpCode::setHandler(OpCode::EXT_STMT, function (ExecutionData $frame): int {
$line = $frame->getOpline()->getLine();
$locals = $frame->getLocalVariables(); // ['n' => ReflectionValue, ...] — by name
return Core::ZEND_USER_OPCODE_DISPATCH;
});- 🎯 Statement-level interception via
EXT_STMT+COMPILE_EXTENDED_STMT - 🏷️ Named local variable access for live stack frames — not slot indices, actual names
- 🚨 Engine error and VM interrupt hooks —
Core::setErrorCallbackHandler(),Core::setInterruptHandler()+Core::$executor->requestInterrupt()— the breakpoint/pause primitives
New in docs/self-debugging.md: the full feasibility study mapping every Xdebug capability to its Z-Engine substitute, what was verified on a live PHP 8.4 build, and what the engine itself closes off (the observer API, the exception-throw hook) — with two honest asterisks about what a pure-PHP debugger cannot do. 📖
✨ New API surface
| Addition | What it gives you |
|---|---|
ReflectionValue::dereference() |
A proper ZVAL_DEREF equivalent |
getFileName() / getFunctionName() |
Closure-safe frame accessors |
ExecutionData::hasThis() |
Baked into getThis() — the HAS_THIS check is no longer yours to remember |
Core::isInitialized() |
Is the engine bridge up? |
Core::sizeOfType() |
sizeof() for any engine type, by name |
ClassSpecializer::evict() |
Destroy a runtime-registered specialization — and it refuses internal, opcache-shared and preloaded entries rather than dismantling memory the process must not touch |
| Named cast-type enums | No more raw integers at cast_object |
🩹 Fixes
- 🔒 Transient hook entries no longer corrupt SHM. They're published on a process-local board instead of the opcache-shared class table — a genuine memory-corruption class of bug, gone.
- ♻️
Core::init()is idempotent per process (#108) — double-boot is a no-op, not a mess. - 💧
pointerAtAddress()stopped leaking a buffer on every call. - 🧯
CastObjectHookfalls through to default engine behaviour safely instead of taking the engine somewhere undefined. - 🔢
_IS_BOOL/_IS_NUMBERcorrected for PHP 8.1+ — these were silently wrong. - 🧩 Module globals cast through the pointer type in
getGlobals()(#109). - 🛡️
ExecutionData::getSymbolTable()guarded by thecall_infoflag — no more reading a symbol table that isn't there.
⚠️ Unchanged: still experimental
Z-Engine operates on raw engine memory. Segfaults are a feature of the territory, not a bug in your code. Do not ship it in production. Match the branch to your PHP minor, keep the JIT off for the debugger primitives, and develop against a debug build.
💙 Thank you, again
Eight platform targets, a ZTS port, a static-analysis layer for FFI and an upstream RFC — in six days, from a solo maintainer's project that had been asleep for five years.
That pace is not a solo pace. Thank you once more to Anthropic and the Claude open-source subscription for maintainers — the cross-platform generator work in particular is exactly the kind of grinding, high-context, low-reward engineering that used to keep these issues open for years. 🙏
Upgrade is a one-liner. Now go run it on your Mac. 🍎
composer update lisachenko/z-engine