PHP RFC: OPcache Static Cache
- Version: 2.0.0
- Date: 2026-06-02
- Author: Go Kudo g-kudo@colopl.co.jp zeriyoshi@php.net
- Status: Under Discussion
- Target Version: PHP 8.6
- Implementation: https://github.com/php/php-src/pull/22052
- Discussion thread: https://externals.io/message/130912
Introduction
This RFC proposes OPcache Static Cache: an OPcache-managed shared-memory cache for explicit userland values and selected PHP static state.
The feature is designed for applications that want faster cross-request caching while continuing to run under the traditional PHP request model. It adds explicit cache classes, OPcache\VolatileCache and OPcache\PinnedCache, with static methods, plus attributes that allow selected static properties and method static variables to survive across requests.
Before OPcache and APCu were split into separate extensions, APC combined opcode caching and userland caching in one place. This proposal moves part of that design space back toward a unified shape, but in a form that matches the modern engine boundary: OPcache already owns persistent script metadata, interned structures, preloading integration, and JIT-adjacent engine hooks, so it is the appropriate subsystem for cache paths that need cooperation from the engine, the VM, and selected internal classes.
Motivation
PHP applications commonly use APCu or external services when they need request-to-request cache state. These options remain useful, but they have costs that are hard to avoid in some workloads.
APCu stores user values outside the request heap. Non-trivial values cross a serialization boundary when stored and again when fetched. Large arrays and object graphs are rebuilt into request-local memory even when the request only needs to read a small part of the value.
The author also attempted to solve these APCu limitations by building colopl_cache, a non-public APCu drop-in extension. That work showed that OPcache compatibility, JIT-heavy workloads, and the Zend VM intervention needed for static-state caching are difficult to provide as an ordinary extension. This RFC therefore proposes implementing the feature in OPcache, where script metadata, shared-memory ownership, JIT assumptions, and VM hooks can be coordinated directly.
Long-running runtimes can keep PHP objects alive across requests, but adopting them safely often requires reviewing request isolation assumptions, service lifetimes, global state, framework bootstrapping, and extension behavior.
Static properties and method static variables are another common cache shape:
final class RouteMetadata { public static function compiled(): array { static $routes = null; return $routes ??= self::compileRoutes(); } }
This is efficient within one request, but the cached state is discarded at request shutdown. Replacing it with a manual cache usually means adding keys, invalidation, fetch/store error handling, and explicit serialization behavior to application code.
There is also a class of applications that already works around this gap by combining preload with persistent arrays or similar engine-adjacent techniques to keep non-volatile derived data alive across requests. That pattern is already used in practice today, but it exists as a workaround rather than as an explicit, supported API shape. If the engine is already being used this way, it is more reasonable to provide a documented API with well-defined behavior than to force each application to reinvent the pattern through preload-specific bootstrapping.
Not every application wants an external master database or cache service for this kind of state. Some deployments are intentionally self-contained and would rather publish authoritative routing tables, metadata, or other derived structures directly into shared memory and fail loudly if those structures no longer fit. For those workloads, an in-memory pinned cache is not merely an optimization; it is a useful programming model.
OPcache Static Cache provides an engine-managed middle ground: selected values can be stored in OPcache-owned shared memory, while normal request isolation remains the default for all code that does not opt in.
Proposal
This RFC adds three related capabilities:
- an explicit OPcache volatile cache API;
- an explicit OPcache pinned cache API;
- attributes that persist selected static properties and method static variables.
Each cache backend defaults to 8 MiB, which is the documented minimum non-zero size. Administrators can disable a backend by setting its size directive to 0, or grow the budget through the new INI directives. When a backend is disabled or cannot be initialized, the new userland functions and attributes are still present, and the status API reports the corresponding backend as unavailable. Explicit store APIs return false for an unavailable backend so libraries can call them opportunistically on hosts where the static cache is disabled; attribute-backed state for that backend falls back to ordinary request-local static behavior.
The implementation keeps two separate shared-memory areas:
- the volatile cache, used by
OPcache\volatile_*and#[OPcache\VolatileStatic]; - the pinned cache, used by
OPcache\pinned_*and#[OPcache\PinnedStatic].
Both areas have separate storage headers, hash tables, allocator state, locks, lookup caches, and status reporting.
Why Two Cache Backends
The volatile cache and the pinned cache serve different roles and intentionally do not share one storage policy.
The volatile cache covers APCu-adjacent use cases in this RFC while recognizing that APCu remains an actively maintained extension. It is for volatile cached data: values that are useful to keep across requests, but may be dropped under memory pressure and rebuilt by application code. These use cases are included here not because APCu lacks maintenance, but because the proposed behavior cannot be achieved by changes limited to APCu: static-property and method-static integration, VM mutation hooks, OPcache invalidation semantics, shared graph pinning, preload, and JIT assumptions all need coordination from OPcache and the engine. This is the backend used by the explicit OPcache\volatile_* API and by #[OPcache\VolatileStatic].
The pinned cache is for cached data that must not silently evaporate once the application has published it. “Pinned” describes the eviction property: entries are not subject to TTL expiry and are not evictable under memory pressure, but they still only live as long as the OPcache static-cache shared-memory segment that owns them. This backend is intended for non-volatile in-memory state such as precomputed metadata, routing tables, or other values where losing the write should be treated as an application error rather than a routine cache miss. This is the backend used by the explicit OPcache\pinned_* API and by #[OPcache\PinnedStatic].
This is not a purely theoretical distinction. In practice, applications already approximate this kind of state through preload and persistent-array workarounds when they want request-to-request data that behaves more like process-owned state than like an evictable cache entry. The pinned cache turns that demand into an explicit API with visible configuration, status reporting, and failure behavior.
The cost of that guarantee is that the pinned cache is intentionally not the cheapest write path. To preserve the “publish now or fail now” contract, pinned-store paths must validate that the value fits in the configured shared-memory budget before committing it. That means additional size calculation and publication overhead compared with the volatile cache. The pinned cache therefore exists because the semantics are useful, not because it is expected to dominate volatile caches on raw write throughput.
Keeping these roles separate lets the volatile cache behave like a recoverable APCu-style cache, while the pinned cache can enforce stricter store-time guarantees and failure behavior for values that are not allowed to disappear before the shared-memory owner restarts.
Public API
The following class and functions are added to the OPcache namespace:
namespace OPcache; class StaticCacheException extends \Exception {} final readonly class StaticCacheInfo { private function __construct() {} public bool $enabled; public bool $available; public bool $startup_failed; public bool $backend_initialized; public int $configured_memory; public int $shared_memory; public int $entry_count; public int $segment_count; public string $shared_model; public ?string $failure_reason; } final class VolatileCache { public static function get(string $key, null|bool|int|float|string|array|object $default = null): null|bool|int|float|string|array|object; public static function getMultiple(array $keys, ?array $default = null): array|false; public static function set(string $key, null|bool|int|float|string|array|object $value, int $ttl = 0): bool; public static function setMultiple(array $values, int $ttl = 0): bool; public static function has(string $key): bool; public static function delete(string $key_or_class): bool; public static function deleteMultiple(array $keys): bool; public static function clear(): bool; public static function lock(string $key, int $lease = 0): bool; public static function unlock(string $key): bool; public static function getCacheStoreType(string $key_or_property, ?string $class_name = null): CacheStoreType; public static function info(): StaticCacheInfo; } final class PinnedCache { public static function get(string $key, null|bool|int|float|string|array|object $default = null): null|bool|int|float|string|array|object; public static function getMultiple(array $keys, ?array $default = null): array|false; public static function set(string $key, null|bool|int|float|string|array|object $value): bool; public static function setMultiple(array $values): bool; public static function has(string $key): bool; public static function delete(string $key_or_class): bool; public static function deleteMultiple(array $keys): bool; public static function clear(): bool; public static function lock(string $key, int $lease = 0): bool; public static function unlock(string $key): bool; public static function increment(string $key, int $step = 1): int|false; public static function decrement(string $key, int $step = 1): int|false; public static function getCacheStoreType(string $key_or_property, ?string $class_name = null): CacheStoreType; public static function info(): StaticCacheInfo; } enum CacheStoreType { case NotFound; case Scalar; case SharedGraph; case OPcacheSerialized; case PHPSerialized; }
The explicit cache APIs, excluding *::info(), return false (or int|false for the pinned atomic methods) for static-cache operation failures. This includes disabled or unavailable backends, storage preparation or allocation failure, lock/header failure, fetch/decode failure, clear/delete failure, and pinned atomic type/storage failure. The explicit cache methods never raise OPcache\StaticCacheException; that exception is reserved for strict #[OPcache\PinnedStatic] publication failures at assignment, mutation, or publication sites. Invalid arguments, such as empty keys, reserved class keys, negative TTL/lease values, unsupported top-level argument types, or Closure arguments, use PHP's ordinary TypeError or ValueError paths. Routine cache misses are not failures: single-key fetches return $default, fetch-array puts per-key defaults in the returned array, and delete of a missing key is a successful no-op when the backend itself is usable.
Volatile Cache API
OPcache\VolatileCache::set() stores a value in the volatile cache and returns whether the value was stored. The optional $ttl is a non-negative integer expressed in seconds. A zero TTL means that the entry does not expire by time.
OPcache\VolatileCache::setMultiple() stores multiple non-empty-string-keyed entries with the same TTL and returns whether every entry was stored. It validates all keys before storing any entry. Keys must be non-empty strings, must not be loaded class names, and must not use the reserved static-cache class key prefixes. If any array key fails validation, a ValueError is thrown and no entry from that call is stored.
OPcache\VolatileCache::get() returns the stored value. If the key is missing or expired, it returns $default. The default default value is null, so routine cache misses do not throw. The first successful single-key fetch for a key and cache epoch attempts to record request-local fetch state as a prototype zval slot reconstructed from the stored payload. Repeated fetches for the same key and epoch copy from that slot when the value is supported by the request-local clone path, so object-free arrays keep PHP's ordinary copy-on-write behavior and avoid repeated PHP value graph reconstruction. For object-bearing values, OPcache treats the slot as a request-local prototype and returns a fresh object graph produced by an internal clone path that does not invoke userland __clone. Ordinary PHP objects use the std-object clone helper, while safe-direct internal objects use per-class copy handlers registered by the owning extension. Object identities that are shared inside one fetched graph remain shared inside that graph, but object handles are not shared with values returned by earlier or later fetches. Mutating a fetched object graph therefore does not mutate another fetched value, the request-local prototype, or the stored cache entry.
Single-key cache keys are non-empty strings; an empty key raises ValueError. Single-key store values and single-key fetch fallback values are typed as null|bool|int|float|string|array|object. PHP does not have a native type spelling for “object except Closure”, so Closure objects are rejected by argument validation. Resources are likewise rejected as public API values or fallback values.
OPcache\VolatileCache::getMultiple() fetches multiple keys from an input key list. Each array element must be a non-empty string or int; ints are converted to their decimal string key, and other types are rejected without invoking userland conversion such as __toString(). Converted keys must not be loaded class names and must not use the reserved static-cache class key prefixes. The whole key list is validated and converted before any cache lock is taken. The return value is an associative array keyed by the converted string keys, or false if the backend operation fails. Its fallback is ?array; missing or expired entries receive null or the supplied array fallback.
$miss = new stdClass(); $value = OPcache\VolatileCache::get('routes', $miss); if ($value === $miss) { $value = build_routes(); OPcache\VolatileCache::set('routes', $value, ttl: 300); }
Applications that need to distinguish a stored null from a miss can choose a sentinel default value or call OPcache\VolatileCache::has() before fetching.
OPcache\VolatileCache::has() checks whether a key exists without decoding the stored value. OPcache\VolatileCache::lock() attempts to acquire a request-retained context/key reservation lock. It returns true when the current request acquired or already owns the reservation, and false when another request currently owns the same key or the same reservation lock stripe. The optional $lease is a non-negative integer expressed in seconds and is not related to cache-entry TTL. With the default 0, request shutdown releases an abandoned reservation. With a positive lease, if the request ends without a successful publish/delete or explicit unlock, OPcache releases the physical process lock at request shutdown but keeps a shared lease marker for up to that many seconds; later builders treat that marker as a temporary reservation until it expires. A later successful store for that key releases the reservation; a successful delete releases the reservation only when the current request owns that exact key reservation. OPcache\VolatileCache::unlock() releases a reservation owned by the current request and returns whether anything was released.
This is the safe entry-building primitive for OPcache Static Cache. It provides the single-builder property needed by APCu-style apcu_entry() use cases without executing userland callbacks while holding the cache write lock. User code observes a miss, reserves the key with OPcache\VolatileCache::lock(), computes the value in the same request, then publishes it with OPcache\VolatileCache::set(). Other requests that try to commit a public store for the same key wait on the reservation and re-check the key after it is released. Destructive operations, including VolatileCache::delete(), VolatileCache::clear(), and opcache_reset(), do not wait for reservation locks; they remove visible entries under the cache write lock and retire any shared-graph payload that may still be referenced by another request. Plain VolatileCache::get() and VolatileCache::has() do not wait for reservations.
For exact-key operations, public explicit-cache keys must not be loaded class names. This prevents a value stored under SomeClass::class from later being confused with the class-scoped attribute deletion form of OPcache\VolatileCache::delete(). Public key arguments also reject the reserved volatile_static_class: and pinned_static_class: prefixes, which are internal storage prefixes for attribute class blobs.
OPcache\VolatileCache::delete(), OPcache\VolatileCache::deleteMultiple(), and OPcache\VolatileCache::clear() remove entries from the volatile cache namespace and return whether the backend operation succeeded. VolatileCache::delete() accepts either an exact key or the name of a class that is already loaded in the current request. If the string names a loaded class, it is treated as a class selector: matching #[OPcache\VolatileStatic] entries owned by that class are removed, and no exact-key delete is attempted for that string. This class-name form does not invoke autoload. Passing a documented static-property or method-static #[OPcache\VolatileStatic] storage key to VolatileCache::delete() or VolatileCache::deleteMultiple() removes that exact attribute-backed entry; see the attribute key section below.
If the volatile cache is unavailable, or cannot store a value due to memory pressure, VolatileCache::set() and VolatileCache::setMultiple() return false after attempting the configured recovery policy when applicable. Other volatile explicit APIs also return false for backend operation failures. The volatile allocator performs proactive fragmentation recovery before the tail allocation area is exhausted: when the remaining tail space is below 3 MiB, or the pending allocation would reduce it below 3 MiB, it may compact movable blocks if free-list fragmentation exists and compaction can actually move a block. If a store allocation still fails, the recovery path first expunges expired entries, then attempts compact-to-fit only when the requested payload can fit in a contiguous free block after compaction. After key validation succeeds, VolatileCache::setMultiple() stores entries in iteration order; if a later value cannot be stored, entries already stored by the same call remain visible.
The volatile API intentionally does not provide increment() or decrement() on VolatileCache. A volatile entry may evaporate because of TTL expiry, explicit clearing, or memory-pressure recovery, so the continuity expected from an atomic counter cannot be guaranteed across requests. Atomic increment and decrement operations are therefore limited to the pinned cache backend.
Pinned Cache API
OPcache\PinnedCache::set() stores a value in the pinned cache and returns whether the value was stored. It has no TTL. The pinned cache is intended for non-volatile cross-request state within the lifetime of the current OPcache static-cache shared-memory segment.
OPcache\PinnedCache::setMultiple() stores multiple non-empty-string-keyed entries and returns whether every entry was stored. Like VolatileCache::setMultiple(), it validates all keys before storing any entry. If any key fails validation, a ValueError is thrown and no entry from that call is stored.
If the pinned cache backend is unavailable, or if an explicit pinned operation cannot complete because the cache is exhausted, a value cannot be encoded, a fetched value cannot be decoded, or an atomic target is not an integer, the explicit pinned API returns false (or int|false for the atomic methods). The explicit pinned methods do not raise OPcache\StaticCacheException. The pinned path may compact allocator fragmentation when movable allocated blocks can be packed so the existing free space becomes the requested contiguous payload block, but it does not expunge TTL entries, evict valid entries, or clear the cache as a pressure recovery path. Attribute-backed #[OPcache\PinnedStatic] state keeps the strict failure mode at assignment, mutation, or publication sites.
OPcache\PinnedCache::get(), OPcache\PinnedCache::getMultiple(), and OPcache\PinnedCache::has() behave like their volatile-cache counterparts, but operate on the pinned cache backend. PinnedCache::lock() and PinnedCache::unlock() use the same request-retained key reservation and optional lease semantics as VolatileCache::lock() and VolatileCache::unlock(). PinnedCache::getMultiple() uses the same non-empty-string/int input key-list and default-value behavior as VolatileCache::getMultiple().
OPcache\PinnedCache::delete() and OPcache\PinnedCache::deleteMultiple() remove entries from the pinned cache namespace and return whether the backend operation succeeded. PinnedCache::delete() accepts either an exact key or the name of a class that is already loaded in the current request. If the string names a loaded class, it is treated as a class selector: matching #[OPcache\PinnedStatic] entries owned by that class are removed, and no exact-key delete is attempted for that string. This class-name form does not invoke autoload. Passing a documented static-property or method-static #[OPcache\PinnedStatic] storage key removes that exact attribute-backed entry; see the attribute key section below.
OPcache\PinnedCache::increment() and OPcache\PinnedCache::decrement() update integer entries under the pinned cache write lock and return the new value, or false on failure. Pinned atomic operations do not accept a TTL argument. PinnedCache::increment() creates a missing key with the value of $step under the reservation lock and cache write lock, then releases any reservation held for that key after the successful write. PinnedCache::decrement() creates a missing key with the value of -$step under the same locks. If the current request already reserved the missing key with PinnedCache::lock(), the atomic operation reuses that reservation and releases it after storing the initial value.
OPcache\PinnedCache::clear() removes entries from the explicit pinned cache namespace and static-state entries stored in the pinned cache backend, and returns whether the backend operation succeeded. It does not clear the volatile cache backend.
The pinned cache is shared with #[OPcache\PinnedStatic]. Implementations may reserve internal key prefixes for static-state storage. Applications should treat OPcache-documented prefixes as reserved once they are specified.
Storable Values
The explicit cache APIs and static attributes use the same value storage machinery. The intended value support is:
| Value kind | Support | Notes |
|---|---|---|
| Scalars | Yes | Stored directly in the cache entry or payload area. |
| Arrays | Yes | Eligible arrays may use the shared-graph representation; otherwise they use serializer fallback. |
| Ordinary objects | Yes, subject to serialization support | Supported object graphs may use shared graph or OPcache serializer paths. Objects that cannot be encoded or serialized fail to store. |
| Internal objects with engine-vetted direct paths | Yes | Only internal classes registered in OPcache's C-level safe-direct handler table may use direct restoration. |
Closure | No | Closure objects are request-local executable state and cannot be represented as stable shared cache values. |
| Resources | No | Resources wrap process-local external handles and are rejected. |
Top-level resources and Closure objects passed to the single-key explicit store/fetch APIs fail argument validation. Resources and Closure objects reached recursively through arrays, object properties, __serialize() result arrays, __sleep() selected properties, setMultiple() values, or static publication are rejected during store preparation. Explicit store paths return false and do not raise OPcache\StaticCacheException. Attribute-backed #[OPcache\PinnedStatic] treats unsupported values as hard storage failures and raises OPcache\StaticCacheException when the pinned backend is available.
Serialization Hooks
If an object's class does not define __serialize(), the value remains eligible for the shared-graph, OPcache serializer, or engine-vetted direct paths when it otherwise satisfies those constraints. A subsequent fetch reconstructs a PHP value without invoking userland code. For object-bearing graphs, the fetched value is structurally equal to the stored value, but each fetch returns independent object handles.
For ordinary objects without a safe-direct registration, a user-defined __serialize() moves the store path to PHP serialization fallback. OPcache calls __serialize() during store preparation, before the cache write lock is taken. On fetch, if the class also defines __unserialize(), that hook is called during value reconstruction after the cache read lock has been released. In other words, an ordinary object with these hooks behaves like a cache round trip through unserialize(serialize($value)), but the userland code runs outside the cache locks.
For classes with a registered safe-direct base, handler policy decides whether user-defined __serialize()/__unserialize() forces fallback. Date/Time handlers allow custom serializers and keep the safe-direct path while encoding normal object properties as part of the stored state. Handlers that disallow custom serializers, such as the supported SPL collection handlers, fall back to PHP serialization when a subclass changes those hooks. Changed __sleep() or __wakeup() handlers always force fallback.
final class Test { public string $a; public string $b; } $t = new Test(); $t->a = 'hello'; $t->b = 'world'; OPcache\PinnedCache::set('t', $t); $loadedT = OPcache\PinnedCache::get('t'); // $loadedT == $t // $loadedT === $t is false; each fetch returns an independent object graph.
Status API
OPcache\VolatileCache::info() returns a readonly OPcache\StaticCacheInfo object for the volatile cache backend. OPcache\PinnedCache::info() returns the same object shape for the pinned cache backend.
| Property | Type | Meaning |
|---|---|---|
enabled | bool | Whether the backend is configured with non-zero memory. This remains true after a startup failure if the directive requested memory; use available when code wants to avoid cache-building work or before calling operations that require a usable backend. |
available | bool | Whether the backend is currently usable: configured, started up, initialized, and not disabled by a startup failure. |
startup_failed | bool | Whether startup disabled the backend after an initialization failure. When true, failure_reason typically holds a human-readable description. |
backend_initialized | bool | Whether the shared-memory backend has been initialized. |
configured_memory | int | Configured memory in bytes. |
shared_memory | int | Shared-memory segment size in bytes. |
entry_count | int | Number of visible entries currently stored in the backend. |
segment_count | int | Number of shared-memory segments backing the backend. |
shared_model | string | Shared-memory backend model name, for example "mmap", "shm", "posix", or "win32". |
failure_reason | ?string | Startup or availability failure reason, or null when no reason is recorded. |
Recommended check pattern for code that wants to avoid work when a backend is not usable:
$info = OPcache\PinnedCache::info(); if ($info->available) { OPcache\PinnedCache::set('routes', $routes); }
Store-only code can also call the store API directly and treat false as “not cached”:
if (!OPcache\PinnedCache::set('routes', $routes)) { // Continue without using OPcache Static Cache. }
opcache_get_status() is extended with volatile_cache and pinned_cache entries whose values are OPcache\StaticCacheInfo objects. opcache_get_configuration() is extended with the new INI directives.
Introspection API
OPcache\VolatileCache::getCacheStoreType() and OPcache\PinnedCache::getCacheStoreType() report how a stored value is represented in shared memory, without decoding it. They return an OPcache\CacheStoreType enum case:
| Case | Meaning |
|---|---|
NotFound | No entry exists for the key (or attribute-backed property) in the selected backend. |
Scalar | Stored directly as a scalar (null, bool, int, float, or string). |
SharedGraph | Stored as a shared graph laid out directly in shared memory, the zero-copy/no-userland-code fast path used for eligible arrays and objects. |
OPcacheSerialized | Stored through the OPcache binary serializer, which is SHM-safe and runs no userland code. This is the first fallback off the shared graph. |
PHPSerialized | Stored through php_var_serialize(), the last-resort fallback that matches APCu's cost model. |
When $class_name is null, $key_or_property is treated as an explicit cache key. When $class_name is given, the lookup targets the attribute-backed static-property storage for $class_name::$key_or_property; a single leading namespace separator is accepted. A disabled or unavailable backend reports NotFound.
This makes the chosen storage strategy observable per key in any build, including the otherwise silent serializer fallbacks, so callers can confirm whether a value took the fast shared-graph path or fell back to serialization.
OPcache\VolatileCache::set('routes', $routes); OPcache\VolatileCache::getCacheStoreType('routes'); // e.g. CacheStoreType::SharedGraph OPcache\PinnedCache::getCacheStoreType('routes', Router::class); // attribute-backed static property
Attributes
The following attributes are added:
namespace OPcache; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] final class PinnedStatic {} enum CacheStrategy: int { case Immediate = 0; case Tracking = 1; } #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] final class VolatileStatic { public readonly int $ttl; public readonly CacheStrategy $strategy; public function __construct(int $ttl = 0, CacheStrategy $strategy = CacheStrategy::Immediate) {} }
No safe-direct marker class or attribute is exposed to userland. Direct restoration is controlled by OPcache's C-level safe-direct handler registry and applies only to internal classes whose owning extension registers handlers for the current PHP build.
#[OPcache\PinnedStatic] stores selected static state in the pinned cache. “Pinned” describes the storage property: such entries are not evictable and have no TTL, but they only live as long as the OPcache static-cache shared-memory segment that owns them. This is for non-volatile in-memory state where losing the write due to memory exhaustion or unsupported values is considered an application error.
#[OPcache\VolatileStatic] uses the same restore machinery, but always stores state in the volatile cache. This is true for all strategies. VolatileStatic never uses the pinned cache backend.
VolatileStatic accepts an optional $ttl argument of type int and an optional $strategy argument of type OPcache\CacheStrategy. The TTL is a non-negative integer expressed in seconds. A zero TTL means that the entry does not expire by time. Each publication of a class, static-property, or method-static entry applies the configured TTL and therefore refreshes the expiration time. The default strategy is OPcache\CacheStrategy::Immediate. OPcache\CacheStrategy::Tracking defers publication until request shutdown and republishes only the static roots or class snapshots dirtied during the request.
Volatile and pinned in these attribute names describe the cache backend, not whether the application runs in a traditional short-lived process or a persistent worker process. In long-running SAPIs such as FPM workers, FrankenPHP workers, or embed users that execute multiple PHP requests in one OS process, CacheStrategy::Tracking still publishes during PHP request shutdown, not process shutdown. OPcache\CacheStrategy::Tracking is available only through VolatileStatic; PinnedStatic does not accept a strategy argument and never uses the tracking strategy.
Both attributes can be applied to:
- a class, persisting the class static properties and method static variables for that class as a class-wide snapshot;
- a static property, persisting only that property;
- a method, persisting only static variables declared in that method.
The attribute form is intentionally more than syntactic sugar over get(). The explicit key/value fetch APIs must return an independent PHP value for each object-bearing fetch, so repeated object reads either reconstruct the PHP value graph from the stored payload or clone from the request-local prototype using OPcache-controlled ordinary-object and safe-direct copy handlers. Attribute-backed static properties and method static variables have different semantics. Once restored into the request's static slot, ordinary reads use that slot directly and do not need to produce a fresh object graph for every access. Safe-direct internal state therefore pays either the restore or prototype-copy cost at explicit-fetch time, but only the static-slot initialization cost for attribute-backed static reads. Attributes cover object-heavy static state without making every read produce a fresh object graph.
Applying both PinnedStatic and VolatileStatic to the same target is a fatal error.
Class-level attributes are not inherited. If a parent class is annotated with #[OPcache\PinnedStatic] or #[OPcache\VolatileStatic], a child class is not automatically cached; the child class must declare its own attribute. Inherited static properties and inherited methods are likewise not treated as child-owned attribute targets.
Example:
#[OPcache\PinnedStatic] final class Metadata { public static array $routes = []; public static function compiled(string $name): mixed { static $index = []; return $index[$name] ??= self::build($name); } }
State is restored lazily when static storage or method static variables are initialized or accessed. The publication point and mutation tracking behavior are controlled by the attribute and, for VolatileStatic, by its strategy. The selected VolatileStatic strategy applies to class, static-property, and method-static targets; CacheStrategy::Tracking is not limited to class-level attributes.
Publication behavior is:
| Attribute target | Backend | Array mutation behavior | Object mutation behavior | Publication point |
|---|---|---|---|---|
#[OPcache\VolatileStatic] or #[OPcache\VolatileStatic(strategy: OPcache\CacheStrategy::Immediate)] | Volatile cache | No recursive tracking after the root snapshot | No recursive tracking after the root snapshot | Root assignments publish an immediate snapshot. |
#[OPcache\VolatileStatic(strategy: OPcache\CacheStrategy::Tracking)] | Volatile cache | Restored reachable arrays are tracked as dirty markers; shared arrays dirty all owners | Restored reachable objects are tracked as dirty markers; shared objects dirty all owners | Request shutdown publishes changed static roots/class blobs; read-only requests skip publication. |
#[OPcache\PinnedStatic] class | Pinned cache | Arrays held by the class blob, without crossing an object boundary, are published immediately after mutation | No recursive object-property mutation publication | Root assignments publish immediately; array mutations publish immediately; storage failure throws OPcache\StaticCacheException at the assignment or mutation site. |
#[OPcache\PinnedStatic] property or method | Pinned cache | Arrays held by the static root, without crossing an object boundary, are published immediately after mutation | No recursive object-property mutation publication | Root assignments publish immediately; array mutations publish immediately; storage failure throws OPcache\StaticCacheException at the assignment or mutation site. |
The practical rule is:
VolatileStaticwithCacheStrategy::Immediatestores the root assignment-time snapshot. Later nested mutations are not published unless the root is assigned again.VolatileStaticwithCacheStrategy::Trackingstores the final tracked state at PHP request shutdown. Restored or assigned arrays and objects reachable from the static root can dirty that root or class blob.PinnedStaticstores root assignments immediately and also publishes array-root mutations immediately. Object-property mutations are not recursively tracked; publish later object state by assigning the static root again.
For CacheStrategy::Immediate, OPcache snapshots the root assigned value at the store point and writes it to the volatile cache immediately. The assigned array or object graph is resolved when the root is assigned, so unsupported values and memory pressure are detected at the store point. Later mutations to the same object or nested array are request-local unless the root static property or method static variable is assigned again.
For method static variables using CacheStrategy::Immediate, OPcache observes the reference update performed by the VM when the static variable receives a new root value. This lets method-level VolatileStatic publish an assignment-time snapshot rather than waiting for request shutdown.
For class-level CacheStrategy::Immediate, static properties and method static variables are stored together as a class blob in the volatile cache. Root static-property assignments and method static-variable assignments update that class blob immediately. Before a class blob is published, OPcache synchronizes initialized method static-variable slots into the blob's method section. Uninitialized method static variables keep their dynamic-initializer sentinel behavior; the blob does not eagerly create null members for them. The class blob uses the volatile-static key namespace and the volatile-cache shared-memory context.
For CacheStrategy::Tracking, OPcache restores the root value, records the original root state, and publishes at request shutdown only when that root or its restored reachable graph changed. Root assignments are detected by comparing the request-shutdown slot value with the original value. Arrays and objects decoded from the restored graph are registered as dirty markers; their mutation hooks mark every owning root or class blob dirty, and the actual store is deferred until request shutdown. If the same restored array or object is reachable from multiple cached roots, mutating that shared dependency causes all affected roots to be published. Tracking starts from the value that has been assigned into the static root; later changes to the outer local variable or temporary that was used for that assignment are not themselves tracked unless they still mutate the same reachable array/object identity now held by the static root. A request that only reads a tracking slot does not republish that slot.
For #[OPcache\PinnedStatic], direct root assignments to static properties and method static variables are treated as immediate pinned-store operations. After the assignment succeeds, OPcache snapshots the root or class blob and stores it in the pinned cache immediately. This validates serialization and shared-memory capacity at assignment time instead of deferring the work until request shutdown.
Arrays currently held by PinnedStatic state are also strict pinned-store state. After an array has been assigned to or restored into the static root, OPcache registers that array and nested arrays reachable from it without crossing an object boundary with the VM mutation hooks. Appending to the array or updating one of its elements publishes the affected root or class blob to pinned cache SHM immediately after the mutation completes, as long as the post-mutation array identity is still reachable from the current static root or class blob through arrays only. If copy-on-write separation moves the write to a local copy outside that boundary, the pending publication is discarded. If the updated static value cannot be stored because the pinned cache is exhausted or the value cannot be encoded, the request throws StaticCacheException.
#[OPcache\PinnedStatic] final class Metadata { public static array $routes = []; } Metadata::$routes = ['foo']; // Stores to pinned cache SHM immediately. Metadata::$routes[] = 'bar'; // Stores the updated array immediately.
Objects assigned to PinnedStatic state are not recursively tracked for later object-property mutations. The assigned object graph is snapshotted when the root static value is assigned or published; subsequent scalar or object-property writes on that object are request-local unless the root static value is assigned again. If the object graph contains an array property, that array is still behind an object boundary and is not registered merely because the object was assigned to PinnedStatic state. Mutating that array property is therefore request-local unless the same array identity is also reachable from a tracked static array root, or the static root is reassigned. This makes PinnedStatic closer to OPcache\PinnedCache::set(): storing a value captures that value at the store point, with the additional strict array-root mutation publication rule described above.
Use CacheStrategy::Immediate when assignment-time snapshots should be stored in the volatile cache without recursive mutation tracking. Use CacheStrategy::Tracking when the final request-shutdown state of the restored or assigned graph should be published. Use PinnedStatic when the pinned cache backend and stricter non-volatile failure mode are desired.
Only CacheStrategy::Tracking defers mutation publication until request shutdown. PinnedStatic array-root edits are deliberately not deferred, so pinned-cache capacity failures are visible at the assignment or array-edit operation that caused them.
Attribute-backed entries are ordinary backend entries under reserved, documented key prefixes. Static-property and method-static keys can be individually removed when necessary. Class blobs are removed by passing the loaded class name to OPcache\VolatileCache::delete() or OPcache\PinnedCache::delete(); the underlying *_static_class: keys are reserved internal keys and are rejected by public key arguments.
| Attribute target | Volatile key | Pinned key | Deleted state |
|---|---|---|---|
| Class | volatile_static_class:ClassName | pinned_static_class:ClassName | Reserved internal key for the whole class blob: cached static properties and method static variables for that class target. Delete with OPcache\VolatileCache::delete(ClassName::class) or OPcache\PinnedCache::delete(ClassName::class). |
| Static property | volatile_static:ClassName::$property | pinned_static:ClassName::$property | That static property target. |
| Method static variable | volatile_static:ClassName::method()::$variable | pinned_static:ClassName::method()::$variable | That method static-variable target. |
ClassName, method, property, and variable are the names stored by the engine for the target declaration; namespaced classes include their namespace separator and do not include a leading slash. These prefixes are reserved by OPcache Static Cache. Applications should not use explicit cache keys starting with volatile_static, volatile_static_class, pinned_static, or pinned_static_class. The class-blob prefixes volatile_static_class: and pinned_static_class: are rejected by the public key APIs.
Passing a loaded class name to OPcache\VolatileCache::delete() or OPcache\PinnedCache::delete() removes all attribute-backed entries for that class in the selected backend. A leading namespace separator is accepted for this class-name form. Loaded class names are not valid explicit keys, so set(SomeClass::class, ...), array store keys named by a loaded class, and other exact-key operations reject them with ValueError. If no matching loaded class is found, single-key delete falls back to exact-key deletion without invoking autoload.
Deleting an attribute-backed key or class removes the shared backend entry. It does not rewrite an already initialized request-local static slot in the current request. If the same request later assigns to or otherwise publishes the same static target, it may create a new entry. The deletion is therefore primarily an inter-request invalidation mechanism: after OPcache\PinnedCache::delete(Metadata::class) or OPcache\PinnedCache::delete('pinned_static:Metadata::$routes'), the next request that initializes that target observes the declared initial value or dynamic initializer result and can rebuild it.
OPcache\VolatileCache::clear(), opcache_reset(), and opcache_invalidate() mark the current request so that request-shutdown publication does not re-publish stale static state after a clear, reset, or invalidation operation.
Reset and invalidation behavior is:
| Operation | Explicit volatile cache | Explicit pinned cache | VolatileStatic state | PinnedStatic state | Notes |
|---|---|---|---|---|---|
OPcache\VolatileCache::clear() | Cleared | Unchanged | Cleared for the volatile-cache backend | Unchanged | Prevents same-request republish of cleared VolatileStatic values. |
OPcache\PinnedCache::clear() | Unchanged | Cleared | Unchanged | Cleared for the pinned-cache backend | Prevents same-request republish of cleared PinnedStatic values. |
opcache_reset() | Cleared | Cleared | Cleared | Cleared | The active Static Cache storage is discarded together with script-cache reset scheduling. In FPM this is the caller's pool partition. |
opcache_invalidate($file) | Unchanged | Unchanged | Only VolatileStatic values belonging to classes in $file are deleted | Only PinnedStatic values belonging to classes in $file are deleted | Explicit key/value entries are not associated with source files and are not deleted. |
| Timestamp revalidation detects that a cached file changed | Unchanged | Unchanged | VolatileStatic values belonging to classes in the changed file are deleted | PinnedStatic values belonging to classes in the changed file are deleted | If a class definition changes across requests, cached state for that class is discarded before the new definition is used. |
INI Directives
Two INI directives are added:
opcache.static_cache.volatile_size_mb=8 opcache.static_cache.pinned_size_mb=8
Backend sizes are configured in megabytes. Both backends default to 8, which is the documented minimum non-zero size. 0 disables the corresponding cache. Non-zero values below 8 are rejected with a warning. The directives are PHP_INI_SYSTEM and must be configured before OPcache shared memory is set up.
opcache.static_cache.volatile_size_mb controls the explicit volatile cache and #[OPcache\VolatileStatic].
opcache.static_cache.pinned_size_mb controls the explicit pinned cache and #[OPcache\PinnedStatic].
Availability is not controlled by an INI directive. OPcache Static Cache is opt-in per SAPI: a SAPI, or an embedding application, enables it for its runtime by calling the internal C API zend_opcache_static_cache_opt_in() before request handling. That call is the runtime's declaration that a trust and storage boundary holds for the lifetime of the shared-memory owner. A SAPI that never opts in -- a shared multi-tenant web runtime without a pre-request tenant boundary -- leaves the backend unavailable, with no override to force it on. SAPIs or embedders that do have a boundary, for example an embed-based application server that scopes state per worker, can instead register their own scoped partitions with the partition API described in the Security and Trust Model section, which also opts the runtime in.
The bundled SAPIs that opt in during startup, and are therefore available by default, are:
clicli-server(the built-in web server)phpdbgfpm-fcgi(which additionally registers one scoped partition per worker pool)
The embed SAPI does not opt in. It is an embedding library, and the embedding application -- not the bundled SAPI -- owns the runtime and its trust boundary, so the embedder decides whether to enable Static Cache and opts in from its own startup code. This keeps the rule consistent for every embedder, including those that register their own SAPI module rather than reusing the bundled embed one (for example FrankenPHP). All other SAPIs, including apache2handler, LiteSpeed/LSAPI, and cgi-fcgi, likewise do not opt in and leave Static Cache unavailable.
Security and Trust Model
OPcache Static Cache is shared memory associated with one PHP runtime instance, except where a SAPI exposes a more specific trust boundary that PHP can select before request handling. Workers that inherit or attach to the same static-cache segment share both the explicit key/value namespace and the attribute-backed static-state namespace. The static-cache segment should not be treated as a tenant-isolation boundary by itself.
FPM is the important special case. FPM worker pools are treated as security boundaries, and the implementation preserves that boundary by creating a separate Static Cache partition for each configured FPM pool before children are forked. Each FPM partition owns one volatile backend and one pinned backend. A child activates the partition for its pool during child initialization, before user code can access Static Cache state. Static Cache APIs, status reporting, explicit clear operations, request-shutdown publication, script invalidation, and the Static Cache portion of opcache_reset() therefore operate on the active FPM pool partition rather than on a cache shared across all pools in the master.
Because FPM creates one volatile and one pinned backend per enabled pool, the configured Static Cache memory budget is per FPM pool. With the default settings, an FPM master with N pools may reserve up to N times the volatile budget plus N times the pinned budget, although payload pages are still touched lazily. Operators with many pools can lower or disable these budgets at master configuration level if that memory tradeoff is not acceptable.
For non-FPM web SAPIs, PHP core does not have a standard tenant or pool identity for apache2handler, LiteSpeed/LSAPI, cgi-fcgi, or arbitrary web process managers that can be selected consistently before Static Cache startup and request handling. Those SAPIs do not opt in, so OPcache Static Cache is unavailable in them even when the size directives are non-zero, and there is no INI override to force it on. A SAPI or embedder that can establish a boundary before request handling enables the cache by calling zend_opcache_static_cache_opt_in() for a single default scope, or by registering isolated scoped partitions with zend_opcache_static_cache_partition_create() and zend_opcache_static_cache_partition_activate() (one volatile and one pinned backend per scope, as FPM does per pool). Deployments that require mutually untrusted tenants to be isolated under a non-FPM web SAPI should use separate PHP runtimes/process groups, containers, virtual machines, or equivalent operating-system isolation so each trust domain has its own Static Cache storage.
The existing opcache.validate_permission and opcache.validate_root directives mitigate script-cache access and chroot key-collision issues by revalidating file access or varying script-cache hashes by root. They do not isolate OPcache Static Cache entries, because explicit cache keys and attribute-backed static-state keys are application data rather than script filenames. This is similar to local shared-memory user caches such as APCu: applications should still namespace keys for correctness, but user-controlled key prefixes are not a security boundary when untrusted code can choose keys or call cache-mutation APIs.
Implementation Notes
The implementation spans the OPcache static-cache subsystem and the engine, JIT, bundled extensions, and SAPIs it integrates with.
OPcache static-cache subsystem:
ext/opcache/zend_static_cache.c
central module lifecycle, request hooks, invalidation, safe-direct handler registration, and the public API method handlers (including
getCacheStoreType());ext/opcache/zend_static_cache_internal.h
shared structs, macros, and small inline helpers for context switching, lookup-cache entries, and simple entry/block operations;
ext/opcache/zend_static_cache_storage.c
shared-memory segment setup, locking, allocator, allocator compaction, per-pool partition creation/activation, and status objects;
ext/opcache/zend_static_cache_entries.c
generic store, fetch, exists, delete, clear, store-type lookup, and pinned atomic update operations;
ext/opcache/zend_static_cache_shared_graph.c
shared graph sizing, in-place SHM build, fetch, build-time string dedup, per-decode class memo, and request pinning;
ext/opcache/zend_static_cache_statics.c
static-cache attributes, static-state keys, restore, publish, mutation tracking, and VM hook handlers;
ext/opcache/zend_static_cache.h
the public opt-in/partition entry points and the shared safe-direct handler table type and registration API used by OPcache and vetted internal extensions;
ext/opcache/zend_opcache_serializer.h
OPcache serializer support and safe-direct paths.
OPcache integration and public API surface:
ext/opcache/ZendAccelerator.c
ext/opcache/ZendAccelerator.h
module startup/shutdown wiring, accelerator globals, and
opcache_reset()/status integration;ext/opcache/zend_accelerator_module.c
ext/opcache/zend_accelerator_module.h
the INI directives and the
opcache_get_status()/opcache_get_configuration()static-cache entries;ext/opcache/opcache.stub.php
ext/opcache/opcache_arginfo.h
the
OPcache\VolatileCache/OPcache\PinnedCacheclasses, theOPcache\CacheStoreType/OPcache\CacheStrategyenums, the attributes,OPcache\StaticCacheInfo/OPcache\StaticCacheException, and the generated arginfo.
Zend engine and JIT:
Zend/zend_execute.c
Zend/zend_execute.h
the class/function static-init, static-property access, reference-assignment, and array/object mutation hooks, with their cold helpers and shared macros;
Zend/zend_vm_def.h
Zend/zend_vm_execute.h
the opcode handlers that invoke those hooks;
Zend/zend.c
Zend/zend.h
Zend/zend_globals.h
the engine hook function pointers and the
EG(tracked_mutation_hooks_active)/EG(static_cache_class_access_active)request guards;Zend/zend_object_handlers.c
the object property/dimension mutation hook sites;
Zend/zend_atomic.h
Zend/zend_portability.h
atomics and portability helpers used by the mutation epoch and the ZTS startup-configuration handoff;
Zend/Optimizer/zend_func_infos.h
optimizer metadata for the new
OPcache\functions;ext/opcache/jit/zend_jit_ir.c
ext/opcache/jit/zend_jit_helpers.c
the JIT static-property fast path kept consistent with the VM static-property hook.
Bundled internal classes with safe-direct handlers:
ext/date/php_date.c
registers the Date/Time safe-direct copy/serialize handlers;
ext/spl/spl_array.c
ext/spl/spl_fixedarray.c
register the supported SPL collection handlers.
SAPIs (per-SAPI opt-in and FPM per-pool partitions):
sapi/cli/php_cli.c
sapi/cli/php_cli_server.c
sapi/phpdbg/phpdbg.c
call
zend_opcache_static_cache_opt_in()beforephp_module_startup();sapi/fpm/fpm/fpm.c
sapi/fpm/fpm/fpm_children.c
sapi/fpm/fpm/fpm_worker_pool.h
per-pool partition creation before fork and activation in children.
The storage layer uses open-addressed entries for keys and a shared-memory allocator for payloads. Backend startup reserves the configured SHM segment and initializes the header and entry table, but it does not eagerly zero the full payload area; allocator blocks are written when payload space is first consumed. The static-cache mmap backend uses its own anonymous shared mapping rather than OPcache's main allocator handlers, so the default non-zero backends can start before requests and alongside tracing JIT/protect_memory without depending on the main OPcache SHM setup. Both the volatile and pinned backends are scoped to the lifetime of the OPcache static-cache shared-memory owner. They are purged when that process, for example an FPM master or other embedding parent, restarts and releases the shared-memory segment. This is intentionally not a durable KVS contract; the implementation favors in-memory speed and does not define a stable on-disk representation, cross-endian format, or cross-build memory-layout compatibility. The backend read/write lock uses byte-range process locks by default. It does not rely on a process-shared pthread rwlock as the default synchronization primitive, because POSIX rwlocks do not provide portable owner-death recovery for a process that terminates while holding the lock.
The allocator has fragmentation recovery paths for low-memory and allocation-failure cases. The volatile backend proactively compacts before the tail allocation area is exhausted: if the remaining tail space is below 3 MiB, or the pending allocation would reduce it below 3 MiB, allocation may pack movable blocks when free-list fragmentation exists and the pass can actually move data. On allocation failure, both backends may attempt compact-to-fit only when the requested payload size can fit into a contiguous free block after movable allocations are packed around immovable anchors. Movable key, string, serialized payload, and unreferenced shared-graph payload blocks are relocated by updating entry offsets. When an unreferenced shared graph moves, OPcache rebases its internal direct-array pointers under the backend write lock. Shared graphs pinned by an active request, or retired while a request still holds a reference, are immovable anchors until the final request reference is released.
Each cache backend has a mutation epoch. Request-local lookup-cache entries include this epoch, so repeated hits and misses can avoid probing the shared table while still invalidating after any writer mutates the segment. The epoch can change within the same request as well as across requests: set(), setMultiple(), delete(), deleteMultiple(), clear(), PinnedCache::increment(), PinnedCache::decrement(), static-attribute publication, opcache_reset(), and script invalidation all mutate the backend entry table when they publish, remove, or clear entries. A write path that expunges expired volatile entries during pressure recovery, or compacts payload blocks and updates entry offsets, also advances the epoch.
The storage layer also provides request-retained reservation locks for lock($key, $lease = 0) and explicit release through unlock($key). The lock is keyed by cache context and key hash, backed on Unix by F_SETLK/F_SETLKW byte-range process locks, and, in ZTS builds, process-local heap-allocated stripe mutexes so threads in the same worker also serialize. The public reservation table records exact keys in request-local state, while the process lock is taken on a fixed stripe derived from the key hash. This means unrelated keys that map to the same stripe may be conservatively serialized, and lock($key) may return false for that temporary stripe contention even when the exact key is not reserved. These are process-associated byte-range locks rather than open-file-description locks: a forked child does not inherit ownership of the parent's byte-range locks, and closing the inherited file descriptor in the child does not release locks owned by the parent process. Public store, store-array entry commits, and pinned atomic mutations acquire the same reservation stripe before taking the cache write lock, unless the current request already owns the exact key reservation. A successful store or pinned atomic mutation for that key releases the reservation; a successful delete releases the current request's exact reservation for the deleted key, if it owns one, without waiting for reservations owned by other requests. Request shutdown releases abandoned zero-lease reservations. For positive leases, request shutdown releases the physical process lock but records a shared expiration timestamp on the reservation stripe; later public locks and blocking store/atomic paths honor that marker until it expires, while explicit unlock, successful publish/delete, clear, and reset remove it immediately. Delete, delete-array, namespace-wide clear operations, and opcache_reset() bypass reservation locks and only take the cache write lock, which avoids reservation-stripe deadlocks between a clearing request and a builder request. Because these destructive operations are not reservation barriers, a request that already owns a reservation may still publish a value after a concurrent delete, clear, or reset. Fetched shared-graph payloads carry cross-request reference state: destructive operations retire referenced payloads and free them only after the last request releases its reference, so removing an entry does not reclaim memory still in use by another request. Forked child processes discard inherited request-local reservation state before acquiring new locks, so they do not accidentally treat a parent request's reservation as their own. In ZTS children, inherited process-local mutex stripes are replaced after fork, the inherited mutex allocations are freed in the child, and the child-owned replacement stripes are released during request shutdown; a later request can lazily create fresh stripes if needed.
Reservation entries carry the owner PID. When a forked child discards inherited reservation entries, it does not unlock stripes owned by the parent process; the inherited process-local mutex stripes are replaced before the child acquires new entry locks.
In ZTS builds, OPcache captures the module-startup configuration that new request threads must copy into thread-local accelerator globals. PHP module startup still precedes request handling, and the handoff also uses an atomic validity flag: startup writes the configuration fields first and then stores the valid flag, while RINIT loads that flag before copying the fields.
Explicit OPcache\VolatileCache::get() and OPcache\PinnedCache::get() also have request-local fetch state keyed by context, key, and mutation epoch. Values reuse the request-local prototype zval slot while the epoch matches when the fetched value is supported by the request-local clone path. Object-free values stay on the fast slot-copy path. Object-bearing values clone object and reference branches out of the prototype for each fetch, without calling userland __clone, so each fetched graph has independent object state. Ordinary PHP objects use OPcache's std-object clone helper, and safe-direct internal objects use the registered per-class copy handler supplied by their owning extension. Mutating a fetched object graph does not dirty the prototype and does not affect earlier or later fetched values.
Large arrays and supported object graphs may be stored as shared graphs. Explicit stores prepare expensive sizing, serializer fallback, and optional scratch buffers before taking the cache write lock, and static-attribute publication does not execute userland serialization hooks while holding the cache write lock. Repeated explicit stores of the same clean source graph in one request may reuse a request-local prepared shared-graph buffer; mutation hooks dirty the prepare memo when reachable source arrays or objects change, and safe-direct/internal objects are excluded from that memo path. Offset-backed payloads may also commit as combined value+key blocks to reduce allocator churn. Shared graphs are rebuilt directly into their final SHM destination during commit. This keeps direct-array payloads tied to the buffer that will later be fetched, instead of byte-copying a prepared buffer whose embedded array data pointers would still point at request-local scratch memory. Fetch decodes also keep userland-visible value reconstruction out of the cache read lock: serialized payload bytes are copied while locked, shared-graph payloads are pinned while locked, and PHP object reconstruction runs after the read lock is released. Fetched shared-graph payloads are pinned until request shutdown. Repeated fetches of the same shared-graph payload in the same request and cache context reuse one request-local pin, so the payload refcount and request-local reference list do not grow with the number of read operations. Deleting or clearing a cache entry removes it from the visible namespace, but the backing payload is not returned to the allocator until active request references have been released. Key reservation locks are not sufficient to make shared-graph relocation safe, because materialized request values can keep payload references after the visible key has changed. Compaction therefore uses shared-graph ref state rather than key ownership: referenced or retired graphs are anchors, while unreferenced graphs may be moved and rebased. If shared-graph restoration fails after a fetch has acquired a payload reference, and releasing that reference makes an already-retired payload eligible for allocator reclamation, the fetch path queues the retired payload and leaves allocator mutation to request cleanup under the cache write lock. Request shutdown clears the static-cache hook fast flags, releases request-held graph references, and frees eligible retired graph payloads under the normal write lock, but it does not run a whole-storage compaction pass.
The VM changes add hooks for class static initialization, function static initialization, static property access, reference assignment, array mutation, and object mutation. Class/function static init and static-property access hooks use the EG(static_cache_class_access_active) request guard, while mutation hooks use EG(tracked_mutation_hooks_active). These guards keep ordinary code to a cheap branch when no static-cache hook is active and prevent hook pointers from being called outside the OPcache static-cache request lifecycle.
The duplicated object-mutation tail checks in the executor, including the object-dimension binary-assignment, object-dimension write, and unset handlers, are funneled through shared
zend_execute.h
macros and cold helpers. The hot path only tests the executor-global fast flag in-line; the zobj != NULL, actual hook pointer, exception-free, and &EG(error_zval) checks run in a zend_never_inline ZEND_COLD helper before zend_tracked_object_mutation_hook() is called. This keeps the many generated VM specializations from inlining the full compound branch in the common no-hook case, while still avoiding a NULL function-pointer call if the fast flag and hook lifecycle ever become temporarily out of phase.
The VM also exposes a post static-property assignment hook used by class-level immediate static-cache attributes. OPcache uses this hook to publish changed class blobs immediately after root static-property assignment. A reference-update hook lets method-level CacheStrategy::Immediate and PinnedStatic publish method static-variable root assignments at the assignment point. Both zend_assign_to_variable() and zend_assign_to_variable_ex() notify this hook after successful updates to a reference cell for assignments whose result is used and for assignments whose result is not used, including assignments through typed references. CacheStrategy::Tracking uses array/object mutation hooks only to dirty-mark restored reachable graph owners; a shared dependency can point at more than one owner root/class blob, publication still happens at request shutdown, and read-only requests skip the store path. PinnedStatic registers arrays reachable without crossing an object boundary, but publishes them immediately through the pinned cache backend rather than dirty-marking them for shutdown. The tracking boundary is the static root and the reachable identities currently stored under it, not the later value changes of the outer variable that was originally assigned into that root.
JIT-generated static-property reads remain hook-able. The JIT fast path does not constant-fold the static slot zval itself: it loads the static-property slot pointer from the run-time cache and copies or returns an indirect reference to the current slot at execution time. When OPcache Static Cache's class-static access hook is active, the JIT static-property fast path invokes the same access hook as the VM run-time-cache static-property path and checks for exceptions before using the resolved slot pointer. This lets class-level snapshots, including preloaded class static slots, refresh their request-local state even when the property slot has already been resolved by the run-time cache. PinnedStatic array mutation publication stores a new shared-memory snapshot and advances the backend epoch; it does not replace the request-local static slot identity, so mutation publication does not require de-optimizing existing traces.
Array mutation hooks are pre-mutation notifications. They are invoked before SEPARATE_ARRAY(), so a write to a refcounted array may notify OPcache about the pre-separation array even though the actual element update happens on a newly separated copy. Static-cache semantics intentionally allow this conservative notification. Direct writes to a tracked static root use the root array identity that OPcache needs for owner discovery. For PinnedStatic's immediate publication path, OPcache re-checks the post-mutation array identity before publishing: the mutated hash must still be reachable from the current static root or class blob through arrays only. Writes through an outer local copy are therefore outside the static root tracking boundary and are not treated as newly tracked static state. CacheStrategy::Tracking still uses the conservative pre-mutation owner discovery to dirty-mark affected owners for request-shutdown publication. Array mutation call sites use EG(tracked_mutation_hooks_active) as the fast guard, then call through zend_maybe_track_hash_mutation(), which verifies the actual hash mutation hook pointer before invoking it.
The assignment fast path reduces request-shutdown work for immediate object assignments: the graph is walked, serialized or encoded, and capacity-checked at the assignment point. PinnedStatic array-root edits use the same strict pinned-store failure mode at the mutation point. The tradeoff is semantic: later object-property writes are intentionally not followed by PinnedStatic or CacheStrategy::Immediate class blobs, and code must reassign the root static value or use CacheStrategy::Tracking to publish a later object state.
Selected internal classes can be registered by their owning extension to allow safe direct restoration and request-local prototype-copy paths. There is no userland-visible marker class or attribute for this. Direct restoration applies only when OPcache finds an internal base class with a registered safe-direct handler table for the current PHP build.
Direct restoration paths for internal classes are intentionally tied to the PHP build that produced the cache payload. Internal layouts in extensions such as ext-date may change over time, but the static-cache implementation is updated together with those layout changes and continues to produce and consume the matching representation for that build. The feature does not provide any operation to export cache data to external storage or import it into a later process lifetime or different PHP build, so cross-version or cross-layout compatibility for cache payloads is not a supported scenario that implementations need to preserve.
The OPcache safe-direct integration is registered through function-pointer tables. OPcache exposes a C-only registration API, and owning extensions register their own const zend_opcache_static_cache_safe_direct_handlers tables during module initialization. ext-date registers DateTime, DateTimeImmutable, DateTimeZone, and DateInterval handlers; ext-spl registers ArrayObject, ArrayIterator, RecursiveArrayIterator, and SplFixedArray handlers. The actual copy, unstorable-state detection, state serialization, and state unserialization callbacks remain private to the owning extension. OPcache uses the same registry from the serializer, shared-graph checks, and request-local prototype clone path. This keeps OPcache from calling ext-date/ext-spl private implementation helpers directly and makes adding another vetted internal class a matter of registering another handler table.
The OPcache serializer decode path does not assume that serialized bytes in shared memory are naturally aligned for C struct access. Generic serializer headers and registered safe-direct state payloads are copied from the payload into aligned local storage or ordinary zvals before their fields are read.
This feature cannot be implemented as a pure userland library with the same behavior. Existing userland libraries or ordinary extension caches can provide fast key/value storage, but they cannot bind cached payloads to PHP static-property and method-static slots, observe VM-level array/object mutations, coordinate with OPcache script invalidation, or keep a shared graph representation pinned safely across request-local zvals. Preload and JIT make that boundary even more important: the cache must cooperate with persistent script metadata, preloaded class state, and VM/JIT assumptions about static storage rather than only storing serialized userland values behind function calls.
Backward Incompatible Changes
No userland behavior changes are intended for applications that do not use the new functions, attributes, or INI directive names.
The two size INI directives default to 8 MiB. Administrators who do not want either backend to be configured at the default minimum can set the corresponding directive to 0. Availability is otherwise an opt-in property of the SAPI rather than an INI setting: FPM, CLI, CLI server, and phpdbg opt in during startup and are available with the default sizes, while the embed SAPI and non-FPM shared-hosting style web runtimes do not opt in and stay unavailable until an embedder enables it from its own startup code. In deployments where mutually untrusted code shares one PHP master process, running tenants under separate PHP masters/containers is the recommended configuration; see the Security and Trust Model section.
The new names in the OPcache namespace become reserved by this RFC.
Proposed PHP Version(s)
PHP 8.6
RFC Impact
To Extensions
ext-date and ext-spl call OPcache's C-only safe-direct registration API during module initialization to register private handler tables for vetted internal classes without relying on userland serialization. They add an OPcache extension dependency for build/link ordering. This is an implementation detail and is not exposed as a userland API.
To OPcache
OPcache gains optional shared-memory areas for volatile/pinned static cache state, cache-specific locks, a mutable allocator, request-local lookup caches, VM hooks for static-state restore/publication, and mutation tracking for arrays and objects.
To SAPIs
SAPIs that use OPcache normally should not need userland changes.
FPM creates one Static Cache partition per configured worker pool after FPM pool configuration has been parsed and before workers are forked. Each partition owns one volatile context and one pinned context, and each child activates its pool's partition during child initialization. If a pool partition cannot initialize its configured backend before workers start, the implementation disables the Static Cache subsystem for that FPM master rather than falling back to a shared global backend or creating worker-local state.
Future Scope
Possible future directions that this RFC does not commit to but does not prevent:
- A higher-level attribute over function return values, memoize-style, layered on top of the same static-cache backends and kept distinct from the static-storage semantics that
PinnedStaticandVolatileStaticdescribe. - SAPI-provided trust-domain identity for non-FPM SAPIs that can expose a stable tenant boundary before request handling. FPM is already handled by per-pool partitions; other SAPIs would need their own SAPI-specific boundary rather than an application-level namespace.
Voting Choices
Voting has not started. The proposed voting questions are:
- Add the explicit volatile cache API,
OPcache\volatile_*. This requires a 2/3 majority. - Add the explicit pinned cache API,
OPcache\pinned_*. This requires a 2/3 majority. - Add the
#[OPcache\VolatileStatic]attribute. This requires a 2/3 majority. - Add the
#[OPcache\PinnedStatic]attribute. This requires a 2/3 majority.
The Doodle blocks below should be opened when voting starts.
Primary Vote requiring a 2/3 majority to accept the RFC:
Performance
The benchmarks in this section were run in the Ubuntu 24.04 devcontainer on a MacBook Air (M4, 32GiB RAM).
Using this benchmark harness application: https://github.com/colopl/php-opcache_static_cache_benchmark_harness
The benchmark resets the relevant state, primes one value, runs warmup requests, and then measures repeated read-only requests. If a measured request misses and would need to build or store the value, the benchmark fails instead of folding that build cost into the sample. The harness supports --runs-on container|devcontainer|local and --target fpm|frankenphp|fpm,frankenphp. The final stdout is DokuWiki text; progress and runtime logs are written to stderr.
The benchmark workload includes framework-shaped route table reads, large array reads, metadata object reads, explicit object fetches that mutate the returned graph before the next fetch, safe-direct handler internal objects, SPL collection objects, Carbon/DateTime objects, and nested-array mutation/publication cases. The harness has class, static property, and method static variable backends for #[OPcache\VolatileStatic(strategy: OPcache\CacheStrategy::Immediate)], #[OPcache\VolatileStatic(strategy: OPcache\CacheStrategy::Tracking)], and #[OPcache\PinnedStatic]. The benchmark tables below report the property and method targets, because those are the static-state shapes most directly comparable to explicit cache reads in the benchmarked workloads.
The benchmark suite includes named scenarios for longer steady-state reads, explicit object fetches that mutate each returned graph, sequential explicit-cache write throughput, 5-way write contention against shared and distinct key layouts, and 5-way single-builder entry reservation contention. The commands in this section use devcontainer runs against NTS php-fpm + nginx and ZTS FrankenPHP, with APCu rebuilt from master and reporting 5.1.29-dev. JIT is disabled for all rows shown here. The final run was rebuilt from ./buildconf --force and fresh out-of-tree NTS/ZTS configure invocations before the scenario matrix was measured; APCu was rebuilt separately for NTS and ZTS, FrankenPHP was rebuilt against the same ZTS static embed library, and all scenarios were run serially from those completed build artifacts.
The NTS FPM build used --enable-cli --enable-fpm --enable-pcntl --enable-session. The NTS CLI startup build used --enable-cli --enable-pcntl --enable-session. The ZTS build used --enable-cli --enable-pcntl --enable-session --enable-embed=static --enable-zend-max-execution-timers --enable-zts --disable-zend-signals, and FrankenPHP was linked against the same ZTS static embed library. All builds also enabled the benchmark-only deepclone backend via --enable-deepclone (the symfony/php-ext-deepclone extension), which the harness uses to measure the userland array-hydration alternative; APCu is loaded as a shared module.
Because OPcache Static Cache is opt-in per SAPI and FrankenPHP registers its own SAPI module rather than reusing the embed SAPI, the FrankenPHP build is patched to opt in. The harness's BUILD_FRANKENPHP step injects a single call to zend_opcache_static_cache_opt_in() into FrankenPHP's frankenphp_startup(), before php_module_startup(), so the backend is enabled while OPcache initializes. The patch rewrites the startup function from:
static int frankenphp_startup(sapi_module_struct *sapi_module) { return php_module_startup(sapi_module, &frankenphp_module); }
to:
static int frankenphp_startup(sapi_module_struct *sapi_module) { extern void zend_opcache_static_cache_opt_in(void); zend_opcache_static_cache_opt_in(); return php_module_startup(sapi_module, &frankenphp_module); }
This is exactly the SAPI-side opt-in that any embed-based application server performs to use Static Cache; the bundled CLI, CLI server, phpdbg, and FPM SAPIs do the equivalent inside php-src, while the embed SAPI leaves the decision to the embedding application.
Read scenarios use vote_read_long with 20 measured iterations, 3 warmup requests, and 3000 operations per request. All read rows had a 100% hit ratio and a max build count of 0. The explicit OPcache\VolatileCache::get() and OPcache\PinnedCache::get() rows reuse request-local lookup state and prototype zval slots when the value is supported by the request-local clone path. Object-free values can be copied from the slot directly; object-bearing values clone object branches from the prototype on every fetch so returned object state is isolated. Ordinary PHP objects use OPcache's internal std-object clone helper, while safe-direct internal objects use registered per-class copy handlers. The fetch_mutate_object scenario uses the same 20/3/3000 shape, but each operation mutates the fetched metadata object graph after probing it, so the OPcache rows measure prototype-clone-after-mutation cost rather than full value reconstruction from storage.
Write scenarios use the same runtime setup: vote_write_throughput uses 15 measured iterations, 2 warmup requests, 128 stores per batch, a single worker, and a 32-key ring; vote_write_contention_shared uses 8 measured iterations, 1 warmup request, 32 stores per worker, 5 workers, and one shared key; vote_write_contention_distinct uses the same 8/1/32 batch shape with 5 workers and a 16-key ring; vote_entry_reservation_contention uses the same 5-worker shared-key shape while racing to populate one missing key with apcu_entry() or lock($key) plus store. The ZTS FrankenPHP runs completed with normal runtime shutdowns.
The benchmark metadata records runtime architecture via php_uname('m') and JIT status from opcache_get_status(). The same named scenarios can be rerun on additional architectures if needed.
CLI startup overhead
The following one-shot CLI run used a separate clean-build NTS CLI binary and the ZTS CLI binary from the clean FrankenPHP build. Each setting executes 10 processes with opcache.enable=1, opcache.enable_cli=1, opcache.jit=0, and both static-cache memory directives set to the same value under -n. The zero row keeps the static-cache backends disabled.
| Runtime | volatile/pinned cache memory | CLI runs | Total time | Mean per run | Overhead vs disabled | Overhead |
|---|---|---|---|---|---|---|
| NTS CLI | 0 MiB | 10 | 28.396 ms | 2.840 ms | +0.000 ms | +0.0% |
| NTS CLI | 128 MiB | 10 | 37.865 ms | 3.787 ms | +0.947 ms | +33.3% |
| NTS CLI | 256 MiB | 10 | 36.804 ms | 3.680 ms | +0.841 ms | +29.6% |
| NTS CLI | 512 MiB | 10 | 37.435 ms | 3.744 ms | +0.904 ms | +31.8% |
| ZTS CLI | 0 MiB | 10 | 23.308 ms | 2.331 ms | +0.000 ms | +0.0% |
| ZTS CLI | 128 MiB | 10 | 34.146 ms | 3.415 ms | +1.084 ms | +46.5% |
| ZTS CLI | 256 MiB | 10 | 33.819 ms | 3.382 ms | +1.051 ms | +45.1% |
| ZTS CLI | 512 MiB | 10 | 33.789 ms | 3.379 ms | +1.048 ms | +45.0% |
Static-cache SHM startup reserves the configured segment before requests start, but initializes only the header and entry table eagerly. Payload pages are touched on demand when entries are first stored. The remaining overhead is therefore small process-startup noise plus fixed metadata setup, and no longer scales linearly with the configured payload size. This is relevant to one-shot CLI invocations, but it is not the steady-state request cost for long-running SAPIs such as FPM or FrankenPHP.
Zend VM/JIT baseline overhead
Because this implementation adds VM hooks and adjusts JIT static-slot handling, the current branch was also compared with clean builds of commit f97ff597429a2fe633665a7e02d97c8077f9f90f using Zend/bench.php. Each row runs 10 one-shot CLI processes and reports the mean of the benchmark's Total line. JIT-off rows use opcache.jit=0; JIT-on rows use opcache.jit_buffer_size=64M and opcache.jit=tracing. The same -d command-line options are passed to both builds; in the base commit the static-cache INI directives are not defined, so those options do not enable any static-cache backend there.
| Runtime | JIT | Static-cache INI | f97ff597 mean | Current mean | Delta | Change |
|---|---|---|---|---|---|---|
| NTS CLI | off | opcache.static_cache.volatile_size_mb=32 | 113.800 ms | 113.100 ms | -0.700 ms | -0.6% |
| NTS CLI | off | opcache.static_cache.pinned_size_mb=32 | 113.500 ms | 113.100 ms | -0.400 ms | -0.4% |
| NTS CLI | off | opcache.static_cache.volatile_size_mb=32, pinned_size_mb=32 | 113.400 ms | 114.000 ms | +0.600 ms | +0.5% |
| NTS CLI | on | opcache.static_cache.volatile_size_mb=32 | 41.200 ms | 41.600 ms | +0.400 ms | +1.0% |
| NTS CLI | on | opcache.static_cache.pinned_size_mb=32 | 41.100 ms | 41.400 ms | +0.300 ms | +0.7% |
| NTS CLI | on | opcache.static_cache.volatile_size_mb=32, pinned_size_mb=32 | 41.500 ms | 41.400 ms | -0.100 ms | -0.2% |
| ZTS CLI | off | opcache.static_cache.volatile_size_mb=32 | 117.100 ms | 118.200 ms | +1.100 ms | +0.9% |
| ZTS CLI | off | opcache.static_cache.pinned_size_mb=32 | 116.600 ms | 117.900 ms | +1.300 ms | +1.1% |
| ZTS CLI | off | opcache.static_cache.volatile_size_mb=32, pinned_size_mb=32 | 117.300 ms | 117.900 ms | +0.600 ms | +0.5% |
| ZTS CLI | on | opcache.static_cache.volatile_size_mb=32 | 42.000 ms | 42.000 ms | +0.000 ms | +0.0% |
| ZTS CLI | on | opcache.static_cache.pinned_size_mb=32 | 42.100 ms | 42.500 ms | +0.400 ms | +1.0% |
| ZTS CLI | on | opcache.static_cache.volatile_size_mb=32, pinned_size_mb=32 | 42.000 ms | 42.100 ms | +0.100 ms | +0.2% |
In this CLI micro-benchmark, the observed current-branch change is within -0.6% to +1.1% across the tested NTS/ZTS, JIT-off/JIT-on, and static-cache backend combinations. This is treated as run-to-run measurement noise for this benchmark, and the implementation is not expected to introduce a measurable VM or JIT performance regression when the feature is present.
Long-read steady state
The following table uses vote_read_long with JIT disabled. Values are mean operation time from the final benchmark summaries. The numbers were re-measured after the shared-graph fetch optimization (skip the request-local prototype and decode straight from SHM for pure shared graphs, while graphs that embed serialized object nodes keep the prototype; build-time string dedup; and a per-decode class memo). The APCu baselines match the previous measurements within roughly 2% on comparable hardware, so the large drop in the volatile_cache/pinned_cache array and object rows versus earlier figures (for example route_table_read 36.178 us to 0.903 us, metadata_object_read 35.110 us to 1.122 us on NTS) reflects that optimization rather than a hardware change. The deepclone column is the userland-hydration alternative requested in review: the value is dehydrated once with deepclone_to_array() (symfony/php-ext-deepclone), kept as a resident array in the volatile cache, and rehydrated to the same object graph on every fetch with deepclone_from_array().
| Workload | Runtime | APCu | volatile_cache | deepclone | pinned_cache | VolatileStatic Immediate property | VolatileStatic Tracking property | PinnedStatic property | VolatileStatic Immediate method | VolatileStatic Tracking method | PinnedStatic method |
|---|---|---|---|---|---|---|---|---|---|---|---|
| route_table_read | php-fpm + nginx (NTS) | 161.230 us | 0.903 us | 0.911 us | 0.912 us | 0.287 us | 0.321 us | 0.320 us | 0.525 us | 0.487 us | 0.350 us |
| route_table_read | FrankenPHP (ZTS) | 158.529 us | 0.880 us | 0.874 us | 0.862 us | 0.257 us | 0.291 us | 0.275 us | 0.431 us | 0.443 us | 0.313 us |
| large_array | php-fpm + nginx (NTS) | 90.873 us | 0.877 us | 0.880 us | 0.876 us | 0.244 us | 0.261 us | 0.247 us | 0.423 us | 0.422 us | 0.289 us |
| large_array | FrankenPHP (ZTS) | 87.957 us | 0.810 us | 0.816 us | 0.813 us | 0.225 us | 0.245 us | 0.241 us | 0.393 us | 0.388 us | 0.273 us |
| metadata_object_read | php-fpm + nginx (NTS) | 185.299 us | 1.122 us | 1.319 us | 1.264 us | 0.317 us | 0.358 us | 0.315 us | 0.493 us | 0.507 us | 0.350 us |
| metadata_object_read | FrankenPHP (ZTS) | 168.119 us | 1.051 us | 1.271 us | 1.050 us | 0.290 us | 0.318 us | 0.290 us | 0.465 us | 0.476 us | 0.330 us |
| safe_direct_object | php-fpm + nginx (NTS) | 2.537 us | 1.218 us | 3.025 us | 0.989 us | 0.490 us | 0.484 us | 0.489 us | 0.677 us | 0.658 us | 0.522 us |
| safe_direct_object | FrankenPHP (ZTS) | 2.440 us | 0.999 us | 2.755 us | 0.949 us | 0.459 us | 0.457 us | 0.458 us | 0.632 us | 0.603 us | 0.491 us |
| spl_collection_object | php-fpm + nginx (NTS) | 21.017 us | 5.483 us | 1.894 us | 5.551 us | 0.366 us | 0.375 us | 0.367 us | 0.538 us | 0.535 us | 0.395 us |
| spl_collection_object | FrankenPHP (ZTS) | 19.328 us | 5.273 us | 1.866 us | 5.250 us | 0.342 us | 0.350 us | 0.346 us | 0.519 us | 0.521 us | 0.379 us |
| carbon_datetime_object | php-fpm + nginx (NTS) | 185.410 us | 46.048 us | 166.299 us | 45.957 us | 1.490 us | 1.499 us | 1.486 us | 1.658 us | 1.671 us | 1.467 us |
| carbon_datetime_object | FrankenPHP (ZTS) | 190.865 us | 46.994 us | 164.721 us | 45.092 us | 1.434 us | 1.444 us | 1.495 us | 1.680 us | 1.714 us | 1.525 us |
| nested_array_assignment | php-fpm + nginx (NTS) | 9.036 us | 0.841 us | 0.852 us | 0.842 us | 0.223 us | 0.222 us | 0.228 us | 0.416 us | 0.391 us | 0.261 us |
| nested_array_assignment | FrankenPHP (ZTS) | 8.727 us | 0.872 us | 0.847 us | 0.819 us | 0.245 us | 0.224 us | 0.231 us | 0.407 us | 0.370 us | 0.260 us |
The deepclone column makes the native-versus-userland-hydration comparison concrete. For the array workloads (route_table_read, large_array, nested_array_assignment) native and deepclone are within noise, because both end up reading a resident immutable array. For object graphs that take the shared-graph fast path, native is faster (metadata_object_read 1.122 us vs 1.319 us NTS). For the safe-direct internal classes native is much faster (safe_direct_object 1.218 us vs 3.025 us, where deepclone is even slower than APCu; carbon_datetime_object 46.048 us vs 166.299 us, about 3.6x). The one workload where deepclone wins is spl_collection_object (1.894 us vs 5.483 us): the SPL collections take the safe-direct serialized-object path, whose per-fetch copy is more expensive than rebuilding from a flat array, so that case stays on the request-local prototype and is a clear target for a future safe-direct copy-handler improvement.
The following focused table uses the same final clean FPM/FrankenPHP runs with JIT disabled. It isolates explicit object fetch behavior, including the mutation-after-fetch workload, and adds the deepclone userland-hydration alternative. The mutation-after-fetch row confirms that, like the native explicit caches, the deepclone path hands back an independent object graph on each fetch, so mutating the fetched value never disturbs the stored entry.
| Workload | Runtime | APCu | volatile_cache | deepclone | pinned_cache |
|---|---|---|---|---|---|
| metadata_object_read | php-fpm + nginx (NTS) | 185.299 us | 1.122 us | 1.319 us | 1.264 us |
| metadata_object_read | FrankenPHP (ZTS) | 168.119 us | 1.051 us | 1.271 us | 1.050 us |
| metadata_object_fetch_mutate | php-fpm + nginx (NTS) | 162.430 us | 1.031 us | 1.187 us | 1.013 us |
| metadata_object_fetch_mutate | FrankenPHP (ZTS) | 168.512 us | 1.062 us | 1.260 us | 1.061 us |
| safe_direct_object | php-fpm + nginx (NTS) | 2.537 us | 1.218 us | 3.025 us | 0.989 us |
| safe_direct_object | FrankenPHP (ZTS) | 2.440 us | 0.999 us | 2.755 us | 0.949 us |
| spl_collection_object | php-fpm + nginx (NTS) | 21.017 us | 5.483 us | 1.894 us | 5.551 us |
| spl_collection_object | FrankenPHP (ZTS) | 19.328 us | 5.273 us | 1.866 us | 5.250 us |
| carbon_datetime_object | php-fpm + nginx (NTS) | 185.410 us | 46.048 us | 166.299 us | 45.957 us |
| carbon_datetime_object | FrankenPHP (ZTS) | 190.865 us | 46.994 us | 164.721 us | 45.092 us |
Explicit-cache write throughput
The following table uses vote_write_throughput. Each cell reports mean store throughput, with mean per-store latency in parentheses.
| Workload | Runtime | APCu | volatile_cache | pinned_cache |
|---|---|---|---|---|
| route_table_read | php-fpm + nginx (NTS) | 10984.67 ops/s (91.036 us) | 7391.64 ops/s (135.288 us) | 7344.28 ops/s (136.160 us) |
| route_table_read | FrankenPHP (ZTS) | 10137.97 ops/s (98.639 us) | 7264.50 ops/s (137.656 us) | 6629.06 ops/s (150.851 us) |
| metadata_object_read | php-fpm + nginx (NTS) | 9802.52 ops/s (102.015 us) | 7060.04 ops/s (141.642 us) | 6962.42 ops/s (143.628 us) |
| metadata_object_read | FrankenPHP (ZTS) | 10139.74 ops/s (98.622 us) | 6946.38 ops/s (143.960 us) | 6797.40 ops/s (147.115 us) |
| safe_direct_object | php-fpm + nginx (NTS) | 415045.40 ops/s (2.409 us) | 147261.85 ops/s (6.791 us) | 148365.66 ops/s (6.740 us) |
| safe_direct_object | FrankenPHP (ZTS) | 289287.33 ops/s (3.457 us) | 105061.56 ops/s (9.518 us) | 114496.99 ops/s (8.734 us) |
| spl_collection_object | php-fpm + nginx (NTS) | 81466.40 ops/s (12.275 us) | 57566.04 ops/s (17.371 us) | 58729.96 ops/s (17.027 us) |
| spl_collection_object | FrankenPHP (ZTS) | 72721.76 ops/s (13.751 us) | 53490.83 ops/s (18.695 us) | 54568.71 ops/s (18.326 us) |
| nested_array_assignment | php-fpm + nginx (NTS) | 178025.03 ops/s (5.617 us) | 88905.35 ops/s (11.248 us) | 88422.22 ops/s (11.309 us) |
| nested_array_assignment | FrankenPHP (ZTS) | 146052.03 ops/s (6.847 us) | 77444.34 ops/s (12.913 us) | 78530.82 ops/s (12.734 us) |
Explicit-cache write contention, shared key
The following table uses vote_write_contention_shared with 5 workers publishing to the same key. Each cell reports mean store throughput, with mean per-store latency in parentheses.
| Workload | Runtime | APCu | volatile_cache | pinned_cache |
|---|---|---|---|---|
| route_table_read | php-fpm + nginx (NTS) | 18366.53 ops/s (54.447 us) | 7707.22 ops/s (129.748 us) | 7677.41 ops/s (130.252 us) |
| route_table_read | FrankenPHP (ZTS) | 15945.59 ops/s (62.713 us) | 6950.55 ops/s (143.873 us) | 7230.05 ops/s (138.312 us) |
| metadata_object_read | php-fpm + nginx (NTS) | 19894.00 ops/s (50.266 us) | 6798.42 ops/s (147.093 us) | 7124.49 ops/s (140.361 us) |
| metadata_object_read | FrankenPHP (ZTS) | 20418.91 ops/s (48.974 us) | 6503.67 ops/s (153.759 us) | 6874.62 ops/s (145.463 us) |
| safe_direct_object | php-fpm + nginx (NTS) | 86131.49 ops/s (11.610 us) | 62111.80 ops/s (16.100 us) | 64360.42 ops/s (15.538 us) |
| safe_direct_object | FrankenPHP (ZTS) | 62466.45 ops/s (16.009 us) | 49185.37 ops/s (20.331 us) | 44234.03 ops/s (22.607 us) |
| spl_collection_object | php-fpm + nginx (NTS) | 50556.92 ops/s (19.780 us) | 45960.50 ops/s (21.758 us) | 47421.46 ops/s (21.088 us) |
| spl_collection_object | FrankenPHP (ZTS) | 49529.85 ops/s (20.190 us) | 36772.10 ops/s (27.195 us) | 37642.63 ops/s (26.566 us) |
| nested_array_assignment | php-fpm + nginx (NTS) | 51994.48 ops/s (19.233 us) | 35704.32 ops/s (28.008 us) | 41271.68 ops/s (24.230 us) |
| nested_array_assignment | FrankenPHP (ZTS) | 50150.84 ops/s (19.940 us) | 38629.85 ops/s (25.887 us) | 40450.01 ops/s (24.722 us) |
Explicit-cache write contention, distinct keys
The following table uses vote_write_contention_distinct with 5 workers publishing to per-worker key rings. Each cell reports mean store throughput, with mean per-store latency in parentheses.
| Workload | Runtime | APCu | volatile_cache | pinned_cache |
|---|---|---|---|---|
| route_table_read | php-fpm + nginx (NTS) | 20544.43 ops/s (48.675 us) | 6974.68 ops/s (143.376 us) | 6958.68 ops/s (143.705 us) |
| route_table_read | FrankenPHP (ZTS) | 16354.90 ops/s (61.144 us) | 6685.68 ops/s (149.573 us) | 6948.67 ops/s (143.913 us) |
| metadata_object_read | php-fpm + nginx (NTS) | 18635.26 ops/s (53.662 us) | 6522.43 ops/s (153.317 us) | 6437.14 ops/s (155.348 us) |
| metadata_object_read | FrankenPHP (ZTS) | 17636.93 ops/s (56.699 us) | 6159.24 ops/s (162.358 us) | 5974.97 ops/s (167.365 us) |
| safe_direct_object | php-fpm + nginx (NTS) | 71416.62 ops/s (14.002 us) | 63079.05 ops/s (15.853 us) | 62030.53 ops/s (16.121 us) |
| safe_direct_object | FrankenPHP (ZTS) | 62223.52 ops/s (16.071 us) | 48778.63 ops/s (20.501 us) | 45482.00 ops/s (21.987 us) |
| spl_collection_object | php-fpm + nginx (NTS) | 50874.40 ops/s (19.656 us) | 47433.76 ops/s (21.082 us) | 48611.90 ops/s (20.571 us) |
| spl_collection_object | FrankenPHP (ZTS) | 51413.88 ops/s (19.450 us) | 41466.89 ops/s (24.116 us) | 40665.90 ops/s (24.591 us) |
| nested_array_assignment | php-fpm + nginx (NTS) | 70757.32 ops/s (14.133 us) | 46092.91 ops/s (21.695 us) | 44781.86 ops/s (22.330 us) |
| nested_array_assignment | FrankenPHP (ZTS) | 52336.75 ops/s (19.107 us) | 40466.63 ops/s (24.712 us) | 37274.32 ops/s (26.828 us) |
Explicit-cache entry reservation contention
The following table uses vote_entry_reservation_contention with 5 workers racing to populate one missing key. APCu uses apcu_entry(); OPcache uses VolatileCache::lock($key) or PinnedCache::lock($key) followed by store. Each cell reports mean operation throughput, with mean per-operation latency in parentheses. Entry reservation rows kept the max build count to 1 in this run, except one NTS volatile cell that reached 3 under the timing of this short contended batch.
| Workload | Runtime | APCu entry | volatile_cache reservation | pinned_cache reservation |
|---|---|---|---|---|
| route_table_read | php-fpm + nginx (NTS) | 5664.69 ops/s (176.532 us) | 44295.26 ops/s (22.576 us) | 43708.38 ops/s (22.879 us) |
| route_table_read | FrankenPHP (ZTS) | 5529.06 ops/s (180.863 us) | 28940.94 ops/s (34.553 us) | 33161.48 ops/s (30.155 us) |
| metadata_object_read | php-fpm + nginx (NTS) | 5576.42 ops/s (179.327 us) | 33428.22 ops/s (29.915 us) | 39166.49 ops/s (25.532 us) |
| metadata_object_read | FrankenPHP (ZTS) | 4014.69 ops/s (249.085 us) | 38286.67 ops/s (26.119 us) | 41129.78 ops/s (24.313 us) |
Where the write rows are slower than APCu, especially the 5-way contended route_table_read and metadata_object_read cases, the difference follows directly from the implementation strategy. A store first goes through
zend_static_cache_entries.c
: zend_opcache_static_cache_prepare_value() classifies the value, may calculate a shared-graph size, may build a shared-graph scratch buffer outside the lock, and otherwise falls back to the OPcache serializer or php_var_serialize(). Then zend_opcache_static_cache_store_prepared_locked() takes the cache write lock, probes the open-addressed entry table, allocates or reuses SHM blocks, copies prepared scalar/string/serialized payloads or rebuilds shared graphs directly into OPcache-owned shared memory, retires or frees overwritten payloads, may expunge expired volatile entries, and may compact fragmented movable payload blocks before reporting allocation failure.
That extra work is not incidental. The static-cache backends must publish values into OPcache-managed SHM in a form that can later participate in request-local lookup caching, shared-graph restoration, static-slot restore/publication hooks, and script invalidation semantics. #[OPcache\PinnedStatic] is stricter still: its store path must validate that the published value fits in shared memory at the assignment or publication point so that exhaustion becomes an immediate error instead of a delayed failure at request shutdown. That size calculation is part of the non-volatile guarantee, not an accidental implementation detail. APCu does not need to preserve those OPcache/VM-level invariants, so its contended write path can be cheaper for the larger graph payloads.
The entry reservation scenario measures a different shape: avoid redundant builder work when concurrent requests observe a miss for the same key. The OPcache path does not execute userland code while holding the cache write lock; it reserves the missing key, builds the value outside the lock, and releases the default zero-lease reservation on successful store or request shutdown. Public mutators from other requests wait for that reservation instead of writing through it. In this run the OPcache reservation rows kept the max build count to 1 across nearly all measured cells (one NTS volatile cell reached 3 under the short contended batch timing), and the reservation path remained substantially cheaper than the apcu_entry() rows measured here.
That said, the proposal is intentionally optimized for read-dominated workloads, not for write-heavy cache churn. In the 100%-hit object-free read rows with max build count 0, once a value has been primed and restored into request-local state, static property targets stay at or below about 0.40 us and method targets stay around 0.30-0.57 us for the representative route-table and large-array rows, while APCu remains around 86-161 us. Explicit cache fetches still cross the key/value API and cache lock path, but remain around 0.8-0.9 us for the route-table and large-array rows. Ordinary object-bearing explicit fetches pay an internal clone cost to keep returned object graphs independent: in the final clean FPM/ZTS runs, the metadata-object fetch path is about 1.05-1.26 us versus APCu at about 168-185 us. The safe-direct, SPL, and Carbon explicit-fetch rows now exercise registered request-local copy handlers for supported Date/Time and SPL internal state, so they avoid repeated reconstruction from the stored payload when the epoch matches. DateTime-shaped safe-direct rows are about 1.0-1.2 us, SPL collection rows are about 5.27-5.55 us, and Carbon rows are about 45.0-47.0 us through the explicit OPcache backends. Attribute-backed static access to the Carbon shape remains about 1.43-1.71 us because restoration is paid once at static-slot initialization. Mutating the metadata object graph before the next fetch does not force full value reconstruction; the next fetch clones again from the request-local prototype, which is still slower than object-free slot copy but remains in the same range as read-only metadata-object fetches. In other words, the slower contended write rows are a secondary cost paid to install a value into the fast path; for repeated-hit request-local lookup, attribute-backed static slots, and supported prototype-copy paths that dominate the target workload, steady-state reads remain substantially faster than APCu, so the write-side disadvantage is acceptable for the intended use cases.
Benchmark takeaways
- The longer 20 x 3000 read scenario confirms the intended fast path under 100% hit ratio and max build count 0: once restored into request-local storage, object-free static property targets stay at or below about 0.40 us and method targets stay around 0.30-0.53 us on both runtimes for the representative route-table and large-array workloads, while APCu remains around 88-161 us. Explicit cache fetches still cross the key/value API and cache lock path, but remain around 0.8-0.9 us for those rows.
- Object-bearing explicit fetches use request-local prototype slots and return independent object graphs through an internal clone path that does not call userland
__clone. In the final clean FPM/ZTS runs, metadata-object repeated fetches are 1.050-1.264 us throughvolatile_cache/pinned_cache, while APCu is 168.119-185.299 us. Mutating the fetched object graph gives 1.013-1.062 us, because the next fetch clones again from the prototype instead of sharing object handles or reconstructing the value from storage. - The safe-direct handler object workloads cover small DateTime-shaped objects and SPL collection graphs backed by
ArrayObject,SplFixedArray,ArrayIterator, andRecursiveArrayIterator. In the final clean FPM/ZTS runs, registered safe-direct copy handlers bring explicit-cache rows to 0.949-1.218 us for DateTime safe-direct objects and 5.250-5.551 us for SPL collections throughvolatile_cache/pinned_cache, while APCu is 2.440-2.537 us and 19.328-21.017 us respectively. - The Carbon workload uses
nesbot/carbonobjects with__serializeand__unserialize, including a 64-entry timeline array of Carbon-derived objects. With the registered Date/Time safe-direct copy handlers, the final clean FPM/ZTS explicit-cache Carbon rows are 45.092-46.994 us throughvolatile_cache/pinned_cache, while APCu is 185.410-190.865 us for the Carbon graph. Attribute-backed static rows for the same object shape remain about 1.434-1.714 us because Date/Time state is restored once into the request static slot rather than on every read. - Repeated explicit
OPcache\VolatileCache::get()andOPcache\PinnedCache::get()calls in these read rows are materially faster than APCu for large read-only graphs because request-local lookup caching and prototype slots avoid repeating shared-table probes and payload bookkeeping on every operation. Object-bearing rows still pay the isolation clone cost on every fetch, so they are slower than object-free slot-copy rows even when no mutation occurs. - Sequential explicit-cache writes trail
apcu_store()in this run: APCu leads the route-table, metadata-object, SPL collection, nested-array, and small safe-direct object write-throughput rows, with the OPcache backends landing at roughly 0.35x-0.72x of APCu throughput (closest on the larger route-table and metadata-object graphs, widest on the small safe-direct and nested-array writes). - Under 5-way store contention, APCu remains faster for the route-table and metadata-object write rows. The smaller safe-direct, SPL, and nested-array payload rows are closer, but APCu still leads the contended-store rows in these runs.
- Under 5-way entry reservation contention,
lock($key)kept the max build count to 1 across nearly all measured cells (one NTS volatile cell reached 3 under the short contended batch timing) and the OPcache reservation rows measured here are much faster than APCu entry rows because only the miss reservation is serialized; the value is built outside the cache write lock and published with a normal store. - Some
#[OPcache\PinnedStatic]patterns are slower because values published to the pinned cache must always fit in shared memory at the assignment or publish point. To enforce the immediate-exception-on-failure rule, the store path computes the size of the value before committing it, and that size calculation adds overhead. - CLI startup absolute deltas remain small because static-cache startup initializes only fixed metadata eagerly and touches payload pages on demand. The measured rows above should be read as one-shot process-startup overhead, where about +0.8 ms to +1.1 ms of process-startup variation can look large in percentage terms against a very small baseline, not the steady-state request cost for FPM or FrankenPHP.
- The
Zend/bench.phpbaseline comparison against commitf97ff597429a2fe633665a7e02d97c8077f9f90fshows a -0.6% to +1.1% change across the tested NTS/ZTS, JIT-off/JIT-on, and 32 MiB static-cache backend combinations. The rows do not show a consistent JIT regression in this rerun, so the RFC treats the range as measurement noise rather than identifying a performance regression caused by adding this feature. - The benchmark harness covers longer reads, write throughput, store contention, entry reservation contention, and architecture/JIT metadata capture. Cross-architecture or full named-scenario JIT-on comparisons require rerunning the same named scenarios.
Validation
The OPcache static-cache PHPT coverage exercises the explicit volatile and pinned cache APIs, non-empty and reserved-key validation, attribute restore and publication, class-name and documented exact-key deletion of attribute-backed entries, clear/reset/invalidate behavior, TTL expiry and large TTL values, entry reservation locks, manual unlocks, post-shutdown lock leases, public mutator waits behind reservations, tracking shared dependencies, pinned failure modes including unsupported PinnedStatic values, allocator reuse after store/delete across requests, forked processes, and ZTS threads, allocator compaction under fragmentation, proactive low-memory compaction below the 3 MiB tail-space threshold, TTL-expunge-before-compaction ordering, skip conditions for unnecessary or impossible compaction, movable unreferenced shared graphs, referenced shared-graph compaction anchors, request-shutdown shared-graph reference cleanup without whole-storage compaction, fetch value reconstruction and userland serialization hooks outside cache locks, request-local object-copy isolation for ordinary objects with userland __clone, safe-direct registered-handler copy/restore paths, hidden safe-direct marker behavior, DateTimeZone/DateInterval/SPL direct paths, request-guarded static init hooks, default non-zero startup with tracing JIT/protect_memory, the per-SAPI opt-in model leaving a non-opted-in cgi-fcgi runtime unavailable, ZTS helper paths, defensive tracked mutation hook helpers, and tracing-JIT static-slot reads after PinnedStatic array mutation publication. The benchmark verification above also builds APCu from master and exercises the NTS FPM and ZTS FrankenPHP runtimes used by the measured rows.
FAQ
Why is the backend named "pinned" rather than "persistent"?
The previous draft used “persistent” for the strict, non-evictable backend. In wider PHP terminology, “persistent” can imply storage that survives process restart or is durable on disk, such as persistent connections or persistent sessions. The strict backend proposed here is none of those: its contents live only as long as the OPcache static-cache shared-memory segment. “Pinned” describes the actual property: entries are not evictable and have no TTL, but they remain in-memory state.
Why do the explicit API and attributes use different error policies?
The explicit public APIs report failures as false (or int|false for the pinned atomic methods) because libraries are likely to use them opportunistically. Disabled or unavailable backends, allocation failures, preparation/encoding failures, fetch/decode failures, clear/delete failures, and pinned atomic type/storage failures return false. The explicit methods never raise OPcache\StaticCacheException. Routine cache misses remain miss-tolerant reads rather than failures: single-key fetches return the supplied default, fetch-array returns an array containing per-key defaults, has() returns false, and delete of a missing key is a successful no-op when the backend is usable. Attribute-backed #[OPcache\PinnedStatic] remains strict and raises OPcache\StaticCacheException because its assignment and mutation sites represent durable-in-this-segment pinned state publication points.
How does this behave in development mode?
Frameworks typically want to disable caches in development so that source-of-truth changes are picked up on the next request. Administrators can set opcache.static_cache.volatile_size_mb=0 and opcache.static_cache.pinned_size_mb=0 in development. Explicit cache calls return false for disabled backends by default, which lets libraries attempt to use the cache without making the application unusable when OPcache Static Cache is turned off. Applications that need to avoid cache-building work entirely can still check StaticCacheInfo::$available before using those paths. Development bootstraps can also use OPcache\VolatileCache::clear(), OPcache\PinnedCache::clear(), or opcache_reset() to force a fresh build on the next request.
For attribute-backed static state, OPcache\VolatileCache::clear() and OPcache\PinnedCache::clear() drop the corresponding cached static-slot data, so the next request re-executes initializers. opcache_invalidate($file) and timestamp-based revalidation also drop cached static state belonging to classes in changed files.
What about shared hosting?
In FPM, OPcache Static Cache is separated per FPM pool. A value stored through an explicit Static Cache API or through #[OPcache\PinnedStatic]/#[OPcache\VolatileStatic] in one pool is not visible from another pool under the same FPM master.
Outside FPM, OPcache Static Cache is available for CLI, CLI server, and phpdbg, because those SAPIs opt in at startup. The embed SAPI does not opt in: embedding applications, including runtimes built on embed and runtimes that register their own SAPI module such as FrankenPHP, own the runtime and its trust boundary, so they decide whether to enable Static Cache and opt in from their own startup code. CLI-style and embed execution do not create a PHP-hosted shared-hosting tenant boundary; embed users are treated as single-application or trusted-application runtimes. Other non-FPM web SAPIs do not opt in either, so Static Cache is unavailable in them and there is no INI override to force it on. A trusted runtime that intentionally accepts runtime-wide sharing can opt in from its own startup code with zend_opcache_static_cache_opt_in(), and a runtime that can scope state per worker or tenant can register isolated partitions with the partition API instead. Deployments where one non-FPM PHP runtime serves mutually untrusted tenants should not opt in; they should set both size directives to 0 or run each tenant under a separate PHP runtime, process group, container, or equivalent OS-level isolation. Per-application key prefixes are useful for correctness, but they are not a security boundary when untrusted code can choose keys or call cache mutation APIs.
Is the cache useful in CLI?
For a typical one-shot CLI invocation, the cross-request benefit is limited because the process exits immediately, as with APCu and OPcache itself. Long-running CLI workers, embed users, and daemonized processes are different: they keep the shared-memory owner alive across many request-equivalent units of work. Administrators who run CLI binaries that never benefit from cross-request state can set both size directives to 0 in CLI-specific configuration.
Patches and Tests
- Implementation: https://github.com/php/php-src/pull/22052
- Benchmark Harness: https://github.com/colopl/php-opcache_static_cache_benchmark_harness
Changelog
- 2026-06-02
- Bumped the RFC document version to 2.0.0 and updated the RFC date to 2026-06-02.
- The explicit cache API is provided as two final classes with static methods,
OPcache\VolatileCacheandOPcache\PinnedCache(no instances and no shared interface). Method names follow PSR-16-style verbs:get(),getMultiple(),set(),setMultiple(),has(),delete(),deleteMultiple(),clear(),lock(),unlock(),getCacheStoreType(), andinfo(), withincrement()anddecrement()added onPinnedCache. Static-cache operation failures returnfalse(orint|falsefor the pinned atomic methods);OPcache\StaticCacheExceptionis reserved for strict#[OPcache\PinnedStatic]publication failures, and invalid arguments raiseTypeError/ValueError. - Added
OPcache\CacheStoreTypeandVolatileCache::getCacheStoreType()/PinnedCache::getCacheStoreType()so callers can observe how a cached value is stored (NotFound,Scalar,SharedGraph,OPcacheSerialized,PHPSerialized) without decoding it, including attribute-backed static-property storage via the optional$class_nameargument. This makes the serializer fallback path observable rather than silent. - Updated the benchmark harness to the current static-method API, fixed its full-matrix assertion to validate per-scenario expected pairs (so intentionally unsupported case/backend combinations such as deepclone for multi-key payloads or static-attribute backends for fetch-mutation payloads are no longer demanded), and re-measured the full read and write scenario matrix on NTS php-fpm + nginx and ZTS FrankenPHP. The read tables match the prior measurements within run-to-run noise (APCu baselines within ~2%), confirming the static-method surface change does not alter the underlying shared-memory cache code path.
- 2026-06-01
- Bumped the RFC document version to 1.4.0 and updated the RFC date to 2026-06-01.
- Updated the Security and Trust Model for the implemented FPM per-pool Static Cache partitions.
- Documented that FPM initializes one volatile and one pinned backend per pool before workers are forked, and that children activate their pool partition before request handling.
- Clarified that the configured Static Cache memory budget is per enabled FPM pool.
- Replaced the SAPI allowlist and the
opcache.static_cache.allow_unsafe_runtimedirective with an explicit per-SAPI opt-in: a SAPI or embedder callszend_opcache_static_cache_opt_in()(or registers a scoped partition) before request handling to enable Static Cache for its runtime. FPM, CLI, CLI server, and phpdbg opt in during startup; the embed SAPI and all other SAPIs leave it to the embedder/runtime, staying unavailable until opted in, with no INI override. - Updated shared-hosting guidance: FPM preserves the pool boundary, while non-FPM shared SAPIs remain disabled by default unless administrators intentionally accept runtime-wide sharing.
- Reran the Static Cache benchmark matrix from clean current NTS FPM, current NTS/ZTS CLI, and current ZTS FrankenPHP builds. Also reran the
Zend/bench.phpCLI baseline comparison against cleanf97ff597429a2fe633665a7e02d97c8077f9f90fCLI builds, then updated the performance tables and benchmark takeaways. - Targeting PHP 8.6
- 2026-05-26
- Bumped the RFC document version to 1.3.0 and updated the RFC date to 2026-05-26.
- Removed the Open Issues section. The former pool/namespace item is now covered by the Security and Trust Model and narrowed to Future Scope.
- This does not change the proposed API, INI directives, default values, implementation semantics, or voting choices.
- 2026-05-19
- Bumped the RFC document version to 1.2.1.
- Changed explicit cache operation failures to return
falseby default and addedbool $throw_on_error = falseso callers can opt intoOPcache\StaticCacheException. - Kept attribute-backed
#[OPcache\PinnedStatic]strict: capacity exhaustion and unsupported encoded values still throwOPcache\StaticCacheExceptionat assignment, mutation, or publication sites.
- 2026-05-19
- Bumped the RFC document version to 1.2 and updated the RFC date to 2026-05-19.
- Renamed the strict backend userland surface from the previous
persistent_*,PersistentStatic,opcache.static_cache.persistent_size_mb, andpersistent_cacheterminology topinned_*,PinnedStatic,opcache.static_cache.pinned_size_mb, andpinned_cache. - Changed the documented defaults for
opcache.static_cache.volatile_size_mbandopcache.static_cache.pinned_size_mbto8MiB, with0as the explicit opt-out value. - Expanded the rationale for the
pinnedname and added a FAQ entry explaining why the strict backend is not calledpersistent. - Expanded
StaticCacheInfo::$availabledocumentation and added a recommended availability-check pattern for code that wants to degrade gracefully. - Clarified unavailable-backend behavior, while attribute-backed static state falls back to ordinary request-local behavior.
- Added a serialization-hooks subsection with a concrete object round-trip example and clarified that userland hooks run outside cache locks.
- Expanded the Security and Trust Model section to describe the static-cache shared-memory segment as the trust boundary and to document shared-hosting guidance.
- Added FAQ entries for development mode, shared hosting, and CLI usefulness.
- Replaced the previous
Memoize-naming open issue with future-scope text for function-return memoization, and added an open issue about possible library/pool namespacing. - Documented the allocator's proactive volatile-cache compaction when remaining tail allocation space is below 3 MiB, or when the pending allocation would reduce it below 3 MiB.
- Documented shared-graph compaction semantics: referenced or retired shared graphs remain immovable anchors, while unreferenced shared graphs may be moved with entry-offset updates and internal pointer rebasing under the backend write lock.
- Clarified that key reservation locks alone are not a shared-graph compaction safety boundary, because materialized request values may keep graph payload references after the visible key has changed.
- Clarified that request shutdown releases shared-graph references and frees eligible retired payloads, but does not run a whole-storage compaction pass.
- Updated validation notes for low-memory compaction, movable unreferenced shared graphs, referenced shared-graph anchors, and request-shutdown shared-graph cleanup.
- Removed the userland-visible
OPcache\__DirectCacheSafeattribute from the specification. Safe-direct restoration is now documented as a C-only OPcache registry used by vetted internal extensions. - Documented that ext-date and ext-spl register their own safe-direct handler tables through OPcache's C API during module initialization, instead of OPcache calling extension-private getter helpers.
- Updated serialization-hook semantics for safe-direct bases: Date/Time handlers may keep the direct path with custom
__serialize()/__unserialize(), while changed__sleep()/__wakeup()or handler policy can force PHP serialization fallback. - Documented request guards for class/function static initialization hooks, and that request shutdown clears the static-cache hook fast flags before graph cleanup.
- Documented the dedicated anonymous mmap backend for static-cache SHM and the default non-zero startup coverage with tracing JIT/protect_memory.
- Documented request-local prepare-memo reuse for repeated stores of the same clean source graph and combined value+key payload allocation.
- Regenerated optimizer function metadata after the pinned rename so
OPcache\PinnedCache::getMultiple()replaces staleOPcache\persistent_fetch_array(). - Updated validation notes for the hidden safe-direct marker, DateTimeZone/DateInterval/SPL direct paths, request-guarded static init hooks, and default tracing-JIT/protect_memory startup.
- 2026-05-18
- Bumped the RFC document version to 1.1 and updated the RFC date to 2026-05-18.
- Added the readonly
OPcache\StaticCacheInfostatus object and changedOPcache\VolatileCache::info(),OPcache\PinnedCache::info(), andopcache_get_status()static-cache entries from status arrays to status objects. - Updated optimizer function metadata, generated arginfo, and status-related PHPT expectations for the new
StaticCacheInforeturn type. - Added the optional non-negative
$leaseargument toOPcache\VolatileCache::lock()andOPcache\PinnedCache::lock(). - Added
OPcache\VolatileCache::unlock()andOPcache\PinnedCache::unlock(), returning whether the current request released a reservation. - Documented and implemented positive lock leases that survive request shutdown as shared lease markers until expiry, while zero-lease locks continue to be released at request shutdown.
- Changed
OPcache\VolatileCache::delete()andOPcache\PinnedCache::delete()to accept either an exact key or a loaded class name/FQCN selector. - Documented and implemented loaded-class deletion for attribute-backed entries:
delete(SomeClass::class)removes matchingVolatileStaticorPinnedStaticstate for that loaded class without invoking autoload and without attempting an exact-key delete for the same string. - Documented exact-key deletion for attribute-backed static-property and method-static entries through the reserved
volatile_static:andpinned_static:key forms. - Reserved the
volatile_static_class:andpinned_static_class:class-blob key prefixes for internal use and documented that class blobs are deleted via loaded class names rather than public exact keys. - Tightened explicit-cache key validation so exact-key operations reject loaded class names and reserved static-cache class-key prefixes.
- Tightened array store, fetch-array, delete-array, lock, unlock, exists, and pinned atomic-operation validation to cover reserved class keys and loaded class names consistently.
- Clarified that volatile-store TTL arguments and
#[OPcache\VolatileStatic]TTL arguments are non-negative integers expressed in seconds. - Clarified that the pinned cache is scoped to the lifetime of the current OPcache static-cache shared-memory segment.
- Clarified that
PinnedCache::setMultiple()validates all keys before storing and throwsValueErrorif any key fails validation. - Changed
OPcache\PinnedCache::decrement()so a missing key is created with-$step, matching the creation semantics ofPinnedCache::increment(). - Documented that userland
__serialize()and__unserialize()hooks run outside cache locks but keep affected object graphs off the fastest shared-graph/direct restoration path. - Documented that volatile and pinned attribute names describe the selected cache backend, not whether the runtime process itself is short-lived or long-running.
- Added practical publication rules for
VolatileStaticimmediate mode,VolatileStatictracking mode, andPinnedStatic. - Documented attribute-backed storage keys, deletion behavior, and the fact that deleting an attribute-backed backend entry does not rewrite an already initialized request-local static slot.
- Clarified that both static-cache backends are purged when the OPcache static-cache shared-memory owner restarts and that this feature is not a durable KVS.
- Updated implementation notes for status objects, reservation locks with leases, shared lease markers, class-key deletion helpers, and static-cache storage versioning.
- Added PHPT coverage for loaded-class attribute deletion, documented exact-key attribute deletion, manual unlock, post-shutdown lock leases, stricter key validation,
StaticCacheInfo, and pinned atomic decrement creation. - Updated existing PHPT coverage to use
StaticCacheInfoobject properties instead of array offsets. - Moved the benchmark harness out of the RFC text and linked it from the Patches and Tests section.
- Reran the benchmark matrix from clean current NTS FPM, current NTS/ZTS CLI, current ZTS FrankenPHP, and clean CLI builds, then updated the performance tables and benchmark takeaways.
- Removed the boilerplate Future Scope text and added an open issue about possible
Memoize-style naming.