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.
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.
<?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.
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 {} } }
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.
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.user_cache has a four-layer caching mechanism. Each value is stored using the fastest layer whose requirements it satisfies.
This is the fastest layer and is available for:
Like OPcache preloading, these are copied directly into shared memory without any additional processing.
This is the second-fastest layer and is available for:
The conditions an object must meet are:
Safe Direct)
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.
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.
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.
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.PHP 8.7 (or 8.6)
None
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:
server_rec).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.user_cache.enable_cli.This isolation is not a best-effort convention; it is enforced by three properties of the design:
CacheStatus::getAvailability()) rather than sharing another boundary's 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.
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.
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.
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.
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.
[UserCache\SafeClonable] attribute. This is useful for third-party libraries such as Carbon.[UserCache\UseCaching] attribute. This enables caching at the class, property, and method levels.Primary vote requiring a 2/3 majority to accept the RFC:
Benchmark runs on Ubuntu 24.04 LTS on OrbStack with MacBook Air 13 M3 16GB RAM