====== PHP RFC: Add bundled user_cache extension ====== * Version: 0.0.1 * Date: 2026-07-19 * Author: Go Kudo * Status: Under Discussion * Implementation: https://github.com/php/php-src/pull/22730 * Discussion thread: https://externals.io/message/132021 * Voting thread: TBD * Previous RFC: [[rfc:opcache_static_cache|OPcache Static Cache]] * Previous Discussion thread: https://externals.io/message/130912 ===== Introduction ===== PHP has historically adopted a "shared-nothing" architecture that frees all request-scoped userland memory at the end of each request. This design is highly beneficial for stable server operation: developers can write code without worrying much about resource leaks, and the cyclic garbage collector is rarely triggered in practice. However, it also means that no user data can be carried over from one request to the next for reuse. Many production applications contain effectively static values, such as routing tables, processed cache data, and master data retrieved from databases. Currently, the common workarounds are OPcache preloading and external extensions such as APCu. However, preloading can only handle scalar values and arrays of scalars, and APCu is constrained by the requirement to serialize and deserialize objects. ===== Proposal ===== Implement a new, always-bundled **user_cache** extension. **user_cache** provides an in-memory cache whose contents survive across request boundaries within the same server process group. ==== Basic usage ==== store('routes', $routingTable, 3600); $routes = $cache->fetch('routes'); // fetch() returns $default on a cache miss. If null (the default) is // a legitimate cached value, pass a sentinel value as $default to // distinguish a miss from a cached null. $miss = new stdClass(); $value = $cache->fetch('maybe-null', $miss); if ($value === $miss) { // cache miss } All TTL values are specified in seconds. A TTL of ''0'' means the entry does not expire. ==== API ==== **user_cache** exposes the following APIs (from **user_cache.stub.php**): */ public function getEntryKeys(): array {} public function getUsedMemory(): int {} } /** @not-serializable */ final class Cache { public static function hasPool(string $pool): bool {} public static function getPool(string $pool): Cache {} /** * Deletes the pool and all of its entries. Values another process is * still reading are freed only after that process releases them. */ public static function deletePool(string $pool): bool {} /** @return array Keys are pool names. */ public static function getPools(): array {} public static function getStatus(): CacheStatus {} private function __construct() {} public function store(string $key, null|bool|int|float|string|array|object $value, int $ttl = 0): bool {} public function add(string $key, null|bool|int|float|string|array|object $value, int $ttl = 0): bool {} public function storeMultiple(array $values, int $ttl = 0): bool {} public function increment(string $key, int $step = 1, int $ttl = 0): ?int {} public function decrement(string $key, int $step = 1, int $ttl = 0): ?int {} public function fetch(string $key, mixed $default = null): mixed {} public function fetchMultiple(array $keys, mixed $default = null): array {} public function delete(string $key): bool {} public function deleteMultiple(array $keys): bool {} public function clear(): bool {} public function has(string $key): bool {} public function lock(string $key, int $lease = 0): bool {} public function unlock(string $key): bool {} /** * Returns the cached value for $key if present; otherwise invokes * $callback, stores its return value with the given $ttl, and returns * it. The callback is invoked even when caching is unavailable, in * which case its result is returned without being stored. */ public function remember(string $key, callable $callback, int $ttl = 0): mixed {} public function getPoolStatus(): CachePoolStatus {} } } === Method reference === Pool management (static methods): * ''getPool()'' returns the ''Cache'' instance for a pool name, creating it on first use. Every operation is scoped to its pool, and pools never observe each other's entries. * ''hasPool()'' reports whether the pool has already been obtained in the current request; ''getPools()'' returns those pools keyed by name. * ''deletePool()'' removes a pool together with all of its entries. Storing values: * ''store()'' writes a value, replacing any existing entry for the key. ''add()'' writes only when the key is absent and returns ''false'' otherwise. * ''storeMultiple()'' stores a key/value map as a unit: if any entry cannot be stored, none of them are. * ''increment()'' / ''decrement()'' adjust an integer entry by ''$step'' and return the new value, or ''null'' on failure. A missing entry starts from ''0''. Reading values: * ''fetch()'' returns the stored value, or ''$default'' on a miss. * ''fetchMultiple()'' returns an array mapping each requested key to its value, or to ''$default'' when the key is absent. * ''has()'' reports whether a live, non-expired entry exists. Removing values: * ''delete()'' removes a single key and ''deleteMultiple()'' removes several. ''clear()'' removes every entry in the current pool. ''remember()'' combines a read and a write: it returns the cached value if present, otherwise it invokes the callback, stores the result with the given TTL, and returns it. The callback still runs when caching is unavailable, in which case its result is returned without being stored. ''lock()'' / ''unlock()'' provide a per-key lock shared across worker processes, intended to let one process recompute an expensive value while others wait. The precise semantics are still under discussion (see Open Issues). ''getStatus()'' returns cache-wide statistics as a ''CacheStatus'', and ''getPoolStatus()'' returns per-pool statistics as a ''CachePoolStatus''. ''CacheStatus::getAvailability()'' reports whether the cache is usable and, if not, why. ==== Configuration ==== All settings are ''PHP_INI_SYSTEM'' and are fixed at startup: * ''user_cache.enable'' (default ''1'') — master switch. When ''0'', the API stays available but every operation behaves as a miss. * ''user_cache.enable_cli'' (default ''0'') — whether the cache is active under the CLI SAPI. * ''user_cache.shm_size'' (default ''16M'') — size of the shared memory segment. * ''user_cache.lockfile_path'' (default ''/tmp'') — directory for the lock file used by the fallback locking model. * ''user_cache.preferred_memory_model'' (default empty) — optional override of the shared-memory implementation selection. ==== Caching mechanism ==== **user_cache** has a four-layer caching mechanism. Each value is stored using the fastest layer whose requirements it satisfies. === Layer 1: direct copy === This is the fastest layer and is available for: * Scalar values * Arrays containing only scalar values Like OPcache preloading, these are copied directly into shared memory without any additional processing. === Layer 2: shared graph === This is the second-fastest layer and is available for: * Objects that meet the criteria below * Arrays containing scalar values or such objects The conditions an object must meet are: * Userland-defined class object, or * Internal class object explicitly marked as safe for direct copying (''Safe Direct'') * Does not have %%__serialize%% / %%__unserialize%% (%%__sleep%% / %%__wakeup%%) magic methods A shared graph is a representation of a ''zval'' that preserves all information but is not serialized. It does not depend on actual memory locations, and array values and properties are processed recursively. If any element processed recursively does not meet the requirements, only that portion falls back to the next layer. === Layer 3: shared graph + magic methods === This layer is used when restoring objects that may cause side effects via **__unserialize** or **__wakeup**. For example, Carbon, a third-party date library, uses these to update **spl_object_hash** stored in its own properties, so executing them is necessary for safe restoration. This involves greater overhead than the plain shared-graph layer due to executing **__unserialize**, but it is still faster than full serialization. === Layer 4: serdes === This is the slowest layer and is used when none of the three layers above can be applied. It performs full serialization and deserialization of values. However, since **user_cache** is a volatile, non-persisted cache, portability is not a concern, so the format is optimized for in-memory use, including memory alignment and binary layout. ===== Backward Incompatible Changes ===== * The **\UserCache\** namespace is reserved. * The ''user_cache.*'' INI namespace is reserved. Since the extension is always bundled, existing third-party extensions or configurations using the same INI prefix would conflict. ===== Proposed PHP Version(s) ===== PHP 8.7 (or 8.6) ===== RFC Impact ===== ==== To the Ecosystem ==== * Users must consider how cached values are shared across requests. * Language servers and static analyzers will need to recognize the classes provided by the bundled **user_cache** extension. ==== To Existing Extensions ==== None ==== To SAPIs ==== Because this feature uses shared memory, each SAPI must opt in by calling a dedicated function; a SAPI that does not opt in never touches the cache. Beyond opting in, each SAPI binds the cache to an isolation boundary that already exists in that SAPI's process model, so the cache never widens a trust boundary the SAPI was not already enforcing: * **php-fpm**: one isolated shared-memory segment per worker pool. A pool is already FPM's unit of privilege separation (each may run under its own user and group), so the cache boundary coincides with the process-privilege boundary. * **apache2handler**: one isolated segment per virtual host (''server_rec''). * **CGI / FastCGI** and **LiteSpeed (LSAPI)**: the segment is keyed by the request's ''DOCUMENT_ROOT'' (or ''SERVER_NAME'' when that is absent), and, on POSIX systems, is further namespaced by the worker's effective UID and GID. Distinct document roots, and distinct users sharing one document root, therefore never share a segment. When neither variable is available the cache is reported unavailable for that request instead of falling back to a shared segment. * **CLI**, the built-in web server, and **phpdbg** run as a single process; CLI is additionally off by default and must be enabled with ''user_cache.enable_cli''. This isolation is not a best-effort convention; it is enforced by three properties of the design: * **Opt-in and fail-closed.** The cache is inert until a SAPI explicitly opts in, and a request that cannot be assigned an isolation boundary is reported as unavailable (through ''CacheStatus::getAvailability()'') rather than sharing another boundary's segment. * **Reuse of an existing boundary.** Each SAPI keys the cache on an isolation unit it already enforces — an FPM pool, an Apache virtual host, or a document root plus effective UID/GID — so no new trust boundary is invented for the cache to get wrong. * **Exact boundary matching.** Boundary segments are located by a full byte-for-byte comparison of the boundary string, not by its hash alone, so a hash collision cannot make two boundaries share a segment. This does not make a misconfigured server safe. As a blanket precaution an administrator can force the feature off for the whole system by setting ''user_cache.enable=0'' in the INI file. The PR already includes these updates for all bundled SAPIs. ===== Open Issues ===== ==== If the cache is disabled, are all functions unavailable? ==== No. The caching API remains available. It simply behaves as if all cache operations fail and no cached data can be retrieved. This allows library developers to incorporate performance optimizations without having to worry about whether caching is enabled. ==== Locking semantics ==== ''lock()'' is a non-blocking try-lock on a per-key, cross-process mutex stored in the shared segment. It returns true when the lock was acquired (or is already held by the calling process) and false immediately otherwise; it never blocks, so userland code cannot deadlock on it. The lock is advisory toward arnforced by the cache itself. ''store()'' / ''add()'' / ''delete()'' / ''increment()'' / ''decrement()'' on a key locked by another process wait up to 10 seconds) and then fail, and ''clear()'' is refused while another live process holds any lock. Read operations (''fetch()'' / ''has()'') are never blocked. The owner of a lock is the acq its pid combined with a process start-time token, so pid reuse cannot be mistaken for the original owner. ''unlock()'' succed children do not inherit ownership. Locks cannot be leaked. With the default ''$lease = 0'', a lock is released by ''unlock()'' or automatically at r terminates abnormally, the dead owner is detected via the pid/start-time probe and the record is reclaimed on the nexhe lease is the upper bound on the lock's lifetime, a lock whose owner has crashed (or that was deliberately left held at d at most until the lease expires and is then reclaimed. Reclamation also frees the key's shared memory, so abandoned lot. Deadlocks across worker proceson: userland acquisition never blocks, the cache's internal per-key waits are bounded by a timeout after which the operng indefinitely, bulk operations acquire per-key locks in a canonical sorted order, and a writer that dies mid-update iss taking the global lock, with locks held by surviving processes preserved across recovery. ==== Why is this no longer implemented in OPcache? ==== I have discontinued the JIT integration and scaled the proposal back to a smaller scope for now. This keeps the proposal's scope well-defined and should allow faster review and decision-making. Performance improvements that require changes to the JIT, as outlined in Future Scope, are best proposed in a follow-up RFC. ==== What has changed from the previous implementation? ==== The implementation, originally based on the in-house **colopl_cache** extension, was rewritten from scratch to improve memory safety, consistency across cache pools, coding style, and code comments. ===== Future Scope ===== * Add ''[UserCache\SafeClonable]'' attribute. This is useful for third-party libraries such as Carbon. * Add ''[UserCache\UseCaching]'' attribute. This enables caching at the class, property, and method levels. ===== Voting Choices ===== Primary vote requiring a 2/3 majority to accept the RFC: * Yes * No * Abstain ===== Implementation ===== * Implementation: https://github.com/php/php-src/pull/22730 * Benchmark result: https://colopl.github.io/php-user_cache_benchmark_harness/ Benchmark runs on Ubuntu 24.04 LTS on OrbStack with MacBook Air 13 M3 16GB RAM ===== References ===== * [[rfc:opcache_static_cache|OPcache Static Cache]] * [[https://externals.io/message/130912|[RFC] [Discussion] OPcache Static Cache (externals.io)]] ===== Changelog ===== * 2026-07-19: Initial