OpCache binary files
August 20, 2026 · View on GitHub
ZEngine\OpCache reads, inspects, patches and rewrites the binary files opcache
writes when opcache.file_cache is enabled (<dir>/<system_id><realpath>.bin).
It turns a cache binary into the same engine-struct wrappers the rest of
z-engine uses, lets you mutate the compiled script through them, and writes a
valid binary back — so the engine loads and executes your patched code on the
next request. This is the foundation for AOP, transpiling and source-code
protection built on top of the file cache.
use ZEngine\OpCache\BinaryCacheFile;
use ZEngine\Reflection\ReflectionValue;
// Generate a cache binary for the current build (opcache compiles it in a child)
$file = BinaryCacheFile::compile(__DIR__ . '/Service.php', $cacheDir);
// Walk the compiled script through the framework wrappers
$reflection = $file->getReflection();
foreach ($reflection->getFunctions() as $name => $function) {
foreach ($function->getLiterals() as $literal) {
// ... inspect or mutate literals, opcodes, flags ...
}
}
// Write the patched binary and invalidate the in-memory copy
$file->refresh();
The file format
A file-cache binary is a zend_file_cache_metainfo header followed by the
serialized script payload and an interned-string section:
| Region | Contents |
|---|---|
Header (CacheMetaInfo) | magic ("OPCACHE"), system_id, mem_size, str_size, script_offset, timestamp, checksum |
Payload (mem_size bytes) | the zend_persistent_script and everything it owns, with every interior pointer stored as a byte offset from the payload start |
String section (str_size bytes) | interned strings, referenced from the payload by a tagged offset |
The payload is position-independent by design, which is what makes it portable between processes — and what the API has to undo before the structures can be walked.
Build matching
A binary is loadable only by the exact engine build that produced it. Three things are checked on load, and the API mirrors each:
- System id —
SystemId::current()is the running build's 32-hex-charzend_system_id.BinaryCacheFile::read()parses the header of a binary from any build, butgetReflection()throwsOpCacheException::systemIdMismatchunless the build matches, because a foreign payload cannot be interpreted. - Checksum — an adler32 over the payload and string section
(
opcache.file_cache_consistency_checks=1).save()always recomputes it, so a patched binary stays loadable.verifyChecksum()performs the same check the loader does. - Timestamp — compared for equality with the source mtime when
opcache.validate_timestamps=1.refresh()stamps the source's current mtime;save($path, $timestamp)lets you set it explicitly.
Reading, patching and writing
BinaryCacheFile::getReflection() materializes the payload into a live
in-memory image (PayloadRelocator, a faithful port of opcache's own
zend_file_cache_unserialize) and returns a ReflectionOpcacheFile — a handle
shaped like the native ReflectionExtension. It hands out ordinary z-engine
wrappers — ReflectionFunction, ReflectionClass, HashTable,
ReflectionValue — so every existing mutation API works on the loaded script:
$reflection->getFileName(); // the cached source path
$reflection->getScriptFunction(); // file-level op_array as a ReflectionFunction
$reflection->getFunctions(); // array<string, ReflectionFunction>, name-keyed
$reflection->getClasses(); // array<string, ReflectionClass>, name-keyed
save() re-serializes the (possibly mutated) image back to a valid binary and
rebuilds the interned-string section, so size-changing edits — a longer
string literal, a new constant value — are written correctly, not just in-place
byte pokes. refresh() is save() plus opcache_invalidate() on the source
script, so the next include picks up the patched binary.
Growing the graph: added functions and methods
In-place edits go out through PayloadRelocator::derelocate() — the exact
inverse of the read-time relocation. Mutations that outgrow the original
buffer take a different writer
(#117):
ScriptSerializer, a two-pass port of zend_persist_calc → zend_persist
(pass 1 walks the graph, deduplicating every reachable allocation unit
through an xlat table and summing aligned sizes; pass 2 emits a fresh
contiguous region and rewrites every pointer), which then delegates the
on-disk offset encoding to the same PayloadRelocator serialize stage — one
implementation for the offset format. save() picks the writer
automatically: it re-emits from scratch once the reflection view reports the
graph as grown, and keeps the byte-exact derelocate path otherwise.
New code enters the image as grafts from donor binaries:
$file = BinaryCacheFile::read($binPath, $scriptPath);
$donor = BinaryCacheFile::compile($donorScript, $donorCacheDir);
$view = $file->getReflection();
$view->addFunctionFrom($donor->getReflection(), 'my_new_function');
$view->addMethodFrom($donor->getReflection(), 'DonorClass', 'newMethod', 'CachedClass');
$file->save(); // a fresh worker now executes the added function and method
Donors are compiled by a real opcache child, so their op_arrays are already
in file form (opline handlers are handler-table indexes, IS_CONST operands
are literal-table indexes — neither is derivable in-process without engine
helpers that are not exported); the serializer copies those units verbatim.
Grafting regrows the target hashtable outside the buffer — persisted tables
must never be touched by zend_hash_add, their data block is not an
emalloc'd allocation — and the donor image stays referenced (and, for
methods, mutated: the op_array's scope is re-pointed at the adopting class)
until save() re-emits everything into one fresh region.
Refresh and shared memory
Under opcache.file_cache_only=1 there is no shared-memory copy, so writing the
binary is enough for the next worker to load it (opcache_invalidate() is a
no-op in that mode). When opcache also uses shared memory, a script already
resident in SHM is not re-read until it is invalidated — which is exactly
what refresh() does.
Two shared-memory subtleties refresh() accounts for:
- Invalidate before write. In a process running SHM with
opcache.file_cache,opcache_invalidate()also unlinks the script's cache binary (zend_file_cache_invalidate).refresh()therefore invalidates first and writes second, so the unlink hits the stale binary — the worst case if the write then fails is a cache miss and a recompile of the original source, never a silently lost patch. - Same-process pickup needs
opcache.revalidate_path=1. After an in-process invalidation, opcache's default key lookup finds the invalidated hash entry without resolving the script path and never consults the file cache again, so a re-include in the same process recompiles the source. Withopcache.revalidate_path=1the path is resolved, the patched binary is loaded from the file cache back into shared memory, and the re-include executes the patched body. A fresh worker (an empty SHM — e.g. a pool worker after restart) picks the patched binary up with default settings.
Publishing a patched binary directly into shared memory (bypassing the file
cache) is not planned — the write-path opcache symbols are hidden from FFI
and the segment is protected against out-of-band writes
(#121, closed with the
feasibility analysis). refresh()'s file-cache→SHM reload is the supported SHM
publication mechanism. Applying a patched image to code already loaded in the
current process is a different loop, closed by CacheImageSync — see the next
section and hot-swap.md.
Applying a patched image to the live process (CacheImageSync)
refresh() only affects the next include. ZEngine\HotSwap\CacheImageSync
closes the other half of the loop (issue #122): it diffs a (patched) image
against the functions and classes already loaded in this process and swaps
the changed compiled bodies in place, through the same runtime machinery
redefine()/ClassDelta use — no re-include, warmed-up call sites keep
dispatching the same entry pointers.
$image = $file->getReflection();
// ... patch literals/opcodes through the wrappers ...
$sync = CacheImageSync::prepare($image); // read-only diff
$sync->getChangedFunctions(); // introspect the plan
$report = $sync->apply(); // swap the changed bodies, loudly
$report->appliedMethods; // what actually happened, per entry
- Diff basis.
prepare()compares each image body with its live counterpart: body metrics (opcode/literal/CV/temporary/argument counts), fn_flags without the storage-only bits, CV names, every opline in canonicalized form (IS_CONST operands by literal index — the image stores the serialized index form, the live side the runtime offset form — with handlers and the garbageop1.numof implicit-$thisreceivers ignored), every literal and static-variable default by value. The comparison is conservative where value equality cannot be proven: array and constant-expression literals always count as changed (a safe re-apply, likeReflectionMethod::equals()); declaration-surface-only edits (arg_info types/names, doc comments) are not part of the basis and do not trigger a swap on their own. - Execution normalization. Donor bodies are materialized per entry
(
ImageFunctionDonor): opcodes + literals are copied into one co-allocated process block, IS_CONST operands are rewritten to the runtime form and the handlers restored with the engine's ownzend_deserialize_opcode_handler(). The image buffer itself is never written, sosave()/refresh()keep producing valid binaries after an apply. - Ordering and atomicity.
apply()validates refusals first (nothing is touched if the plan contains one), then copies every opcache-shared target out of SHM, then stages all swaps — functions before classes, alphabetically within each group — and commits only when every swap staged; a failure rolls all staged bodies back (completed copy-outs stay, they are behavior-preserving). - Scope. Bodies of named global functions and of methods the live class itself declares. Image-only entries (script never included here, methods or functions only the patch added) are reported as not loaded — the next include picks them up. The script's main op_array, class constants, property defaults and attributes are out of scope.
- Refusals (throw-or-work, never silent). Changed methods of an
enum/interface/trait throw
HotSwapException::unsupportedKind; an image entry colliding with an internal function/class throws; opcache-shared targets follow the hot-swap.md copy-out matrix, so preloaded classes and copy-unsupported shapes (property hooks, internal ancestors) throwSharedMemoryException. Unchanged entries of a refused kind are not operations and pass. - Lifetime. Swapped-in bodies execute out of the materialized blocks and the relocated image buffer: the sync retains both (and the view retains the buffer), all are request-lifetime allocations the engine provably never frees through table teardown (the bodies carry no refcount, exactly like shared-memory bodies). Apply per request, like every other runtime mutation.
- Apply-target seam.
prepare()is application-agnostic: the prepared diff (getChangedFunctions()/getChangedMethods()plus the image handle) is independent of where the swapped bodies land. Todayapply()writes the per-process tables; a different consumer could reuse the same diff against another target. Direct SHM publication is not one of those targets (#121 — infeasible vs stock opcache); the file-cache→SHM reload ofrefresh()covers that need.
Scope and limits (v1)
- Platform. The relocator targets 64-bit POSIX builds - linux and macOS
(x64 and arm64) alike; it asserts
PHP_INT_SIZE === 8and a/path separator and throwsOpCacheException::unsupportedPayloadotherwise. Darwin needs no per-opline walking of its own (#119): the absolute-address opline branches of zend_file_cache.c (ZEND_USE_ABS_CONST_ADDR/ZEND_USE_ABS_JMP_ADDR) are compiled in only whenSIZEOF_SIZE_T == 4(zend_compile.h), so every 64-bit build - darwin included - stores IS_CONST operands as literal-table indexes and jumps as opline-relative byte offsets, both position-independent and preserved verbatim.OpcodeAddressingModelTestproves that on a real payload and fails loudly if a build ever diverges; the 32-bit builds that do use absolute addressing are refused by thePHP_INT_SIZEpredicate. Windows opcache support is an intentional non-goal, not pending work: the relocator (andopcache.preload-based features) keep rejecting Windows loudly, and the Windows half of the original platform ticket was retired when #119 was rescoped to macOS/arm64. ZTS payloads are supported since #118: the file-cache binary layout is thread-safety-agnostic (zend_file_cache.c has no ZTS conditionals, and every struct the walker dereferences is layout-identical across the modes — only EG/CG/module_entry differ, none of which appear in a payload). - Strict, never silent. Anything the port cannot handle raises
unsupportedPayloadrather than writing a subtly corrupt binary; with every payload shape of the engine's walker now ported (the PHP 8.5-only shapes - attributed constants, closures in constant expressions - included), that guard covers the platform predicates above (Windows/32-bit). Global functions, classes with constants, typed properties (union/intersection/DNF type lists included), trait-using classes (aliases and insteadof precedences included), closures and arrow functions (nested dynamic_func_defs included), Iterator/IteratorAggregate/ArrayAccess classes (including the linked-class iterator_funcs_ptr / arrayaccess_funcs_ptr structs), property hooks, attributes (including constant-expression arguments), static variables, compile warnings, try/catch and enums are supported and round-trip byte-for-byte. - Graph growth. Added functions and methods are supported through donor
grafts and the from-scratch
ScriptSerializer(see "Growing the graph" above, issue #117); whole added classes and freshly in-process compiled op_arrays (no file-form oplines) remain out of scope and are refused loudly. - Deferred. Loading patched binaries into shared memory (ZCSG,
#121). Applying a
patched image to already-loaded functions and classes landed as
CacheImageSync(see above).
Trust model — the .bin input must be trusted
Loading a cache binary is loading code. The relocator turns stored byte
offsets into real engine addresses that the interpreter then executes, so a
.bin file is exactly as trusted as the PHP source it was compiled from. Treat
it that way: read binaries only from a location your own deployment controls.
Two header fields look like integrity checks but are not authentication:
system_idis a build fingerprint — a hash of the PHP version, extension set and build flags. It exists so a binary compiled by one build is refused by an incompatible one (systemIdMismatch), preventing accidental ABI mismatch. It says nothing about who produced the binary; anyone can compute the current build'ssystem_idand stamp it on a crafted file.checksumis an adler32 of the payload. It catches accidental corruption (a truncated write, a bad disk block). adler32 is trivially forgeable — an attacker who alters the payload simply recomputes it — so it is not tamper protection against a motivated adversary.
Because neither field authenticates the producer, the relocator does not
rely on them for safety. Instead, every stored offset, count and element span
is bounds-validated against the declared buffer before it is dereferenced
(issue #123): interior-pointer offsets against [0, memSize], tagged
interned-string offsets against [0, strSize), scriptOffset and every
count-driven element array (hashtable buckets, literals, arg_info, vars, type
lists, class/trait names, property hooks, dynamic_func_defs, warnings, early
bindings, …) against the region bounds. A violation raises
OpCacheException::malformedPayload — a loud refusal, never an out-of-bounds
engine read/write. The validation lives in the relocate() (read) path, the
untrusted-input surface; derelocate()/serialize() and the graph
ScriptSerializer operate on an already-relocated, in-process image and
inherit that validation. This is defense in depth, not a licence to load
untrusted binaries: it converts a memory-safety catastrophe into a clean
exception, but a validated binary can still contain hostile compiled code.
Distributing protected binaries. If you need to ship binaries across a trust
boundary (a build server to production hosts, say), authenticate them yourself
with a keyed MAC or a signature over the file before loading — e.g. an
HMAC-SHA256 with a deployment secret, verified before BinaryCacheFile::read().
A built-in keyed-MAC mode is a possible future option (a follow-up to issue
#123); it is deliberately not part of this version, because the right key
management belongs to the deploying application, not the library.
Failure modes
Everything the API rejects is a static factory on OpCacheException
(invalidMagic, truncatedFile, systemIdMismatch, checksumMismatch,
binFileNotFound, compilationFailed, unsupportedPayload,
malformedPayload, …), so call sites read as intent and the wording lives in
one place.