PHP RFC: Add bundled user_cache extension
- Version: 0.0.1
- Date: 2026-07-19
- Author: Go Kudo g-kudo@colopl.co.jp zeriyoshi@php.net
- Status: Under Discussion
- Implementation: https://github.com/php/php-src/pull/22730
- Discussion thread: https://externals.io/message/132021
- Voting thread: TBD
- Previous RFC: 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
<?php use UserCache\Cache; $cache = Cache::getPool('default'); // store() overwrites an existing entry; add() fails if the key exists. $cache->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):
<?php /** * @generate-class-entries * @generate-c-enums */ namespace UserCache { enum CacheAvailability { case Available; case DisabledByIni; case DisabledBySapi; case UnavailableBySharedMemoryInitializationFailed; case UnavailableByBackendNotInitializedBeforeWorkerStartup; case UnavailableByBackendInitializedAfterWorkerStartup; case UnavailableByCgiFastCgiBoundary; case UnavailableByLsapiBoundary; case UnavailableByUnknownReason; } /** * @strict-properties * @not-serializable */ final readonly class CacheStatus { private function __construct() {} public function getAvailability(): CacheAvailability {} public function getConfiguredMemory(): int {} public function getSharedMemorySize(): int {} public function getUsedMemory(): int {} public function getFreeMemory(): int {} public function getWastedMemory(): int {} public function getEntryCount(): int {} public function getEntryCapacity(): int {} public function getTombstoneCount(): int {} /** * Number of times memory pressure forced a full cache wipe. Routine * removal of expired entries is not counted. */ public function getExpungeCount(): int {} public function getStoreFailureCount(): int {} /** * Pin-owner slots currently claimed by processes that hold (or have * held) references into shared-graph payloads. */ public function getGraphPinSlotsInUse(): int {} /** * Shared-graph payload references currently pinned across all * processes. Requests holding decoded array/object values keep pins * until they end. */ public function getGraphPinnedReferences(): int {} /** * Cumulative count of pin owners that terminated abnormally (SIGKILL, * OOM) while holding shared-graph references and were reclaimed by the * dead-pin sweep. A rising value indicates worker kills; pair with * getDeadPinsStripped() for the number of references recovered. */ public function getDeadPinOwnersReclaimed(): int {} /** Cumulative shared-graph references stripped from dead owners. */ public function getDeadPinsStripped(): int {} } /** * @strict-properties * @not-serializable */ final readonly class CachePoolStatus { private function __construct() {} public function getPoolName(): string {} public function getEntryCount(): int {} /** @return list<string> */ 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<string, Cache> 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 theCacheinstance 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 returnsfalseotherwise.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$stepand return the new value, ornullon failure. A missing entry starts from0.
Reading values:
fetch()returns the stored value, or$defaulton a miss.fetchMultiple()returns an array mapping each requested key to its value, or to$defaultwhen the key is absent.has()reports whether a live, non-expired entry exists.
Removing values:
delete()removes a single key anddeleteMultiple()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(default1) — master switch. When0, the API stays available but every operation behaves as a miss.user_cache.enable_cli(default0) — whether the cache is active under the CLI SAPI.user_cache.shm_size(default16M) — 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(orSERVER_NAMEwhen 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:
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
Changelog
- 2026-07-19: Initial