[PHP-DEV] [RFC] [Discussion] OPcache Static Cache

Hi internals,

I’d like to start the discussion for a new RFC, OPcache Static Cache.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

The proposal adds an OPcache-managed shared-memory cache for explicit userland values and for selected PHP static state. It introduces explicit functions under the OPcache namespace (volatile_* and persistent_*) and two attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that let selected static properties and method static variables survive across requests. The feature is disabled by default and only activates once memory is allocated through the new INI directives.

The RFC covers the motivation, the deliberate split between the two backends, the trust model (one PHP runtime = one trust domain; this is not a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage summarized in the Validation section.

One thing to flag on the implementation status: the Windows build is currently broken. I don’t have a Windows development environment available yet — one is being arranged through work, and I’ll get the Windows side fixed once that’s in place.

Feedback welcome.

Best Regards,
Go Kudo

On Sat, May 16, 2026, at 10:19 AM, Go Kudo wrote:

Hi internals,

I'd like to start the discussion for a new RFC, OPcache Static Cache.

RFC: PHP: rfc:opcache_static_cache
Implementation: [RFC] OPcache Static Cache Implementation by zeriyoshi · Pull Request #22052 · php/php-src · GitHub

The proposal adds an OPcache-managed shared-memory cache for explicit
userland values and for selected PHP static state. It introduces
explicit functions under the OPcache namespace (volatile_* and
persistent_*) and two attributes, #[OPcache\VolatileStatic] and
#[OPcache\PersistentStatic], that let selected static properties and
method static variables survive across requests. The feature is
disabled by default and only activates once memory is allocated through
the new INI directives.

The RFC covers the motivation, the deliberate split between the two
backends, the trust model (one PHP runtime = one trust domain; this is
not a tenant isolation boundary), and benchmarks against APCu on NTS
php-fpm and ZTS FrankenPHP. The PR is the full implementation, with
PHPT coverage summarized in the Validation section.

One thing to flag on the implementation status: the Windows build is
currently broken. I don't have a Windows development environment
available yet — one is being arranged through work, and I'll get the
Windows side fixed once that's in place.

Feedback welcome.

Best Regards,
Go Kudo

Interesting! I can definitely see uses for it, and I appreciate the level of detail in the RFC.

Some thoughts, though:

- atomic-decrement throwing if a value doesn't exist sounds like a footgun. For something like an up/down voting widget, I could very easily see someone hitting the Down Vote button first, which would cause it to crash. Having to always check _exists() on decrement but not on increment is inconsistent and likely to confuse people.

- Are either of these stores purged on reboot?

- "persistent cache and returns void on success." - No, returns null. You can't return void. A function can have a void return type, whether it's successful or not. Not quite the same thing.

- Having the locks automatically self-unlock sure sounds elegant at first, but the lack of symmetry in the API feels very error prone. People will want to use it like a transaction, but since it unlocks on the first write there's no way to make it one. It just silently unlocks if certain functions are called. But if they're not called, there's no way to unlock it. That's even more of an issue in a persistent-process use case, where you could easily not hit the process-end for minutes or hours, so the lock never automatically clears.

Use case: You need to update some lookup table, so you lock the stored key, compute the new table, then write the new table. But if the compute step fails for some reason, you now have a locked value with no way to unlock it, but no new value to write to it. It's better in many cases to leave the stale data there rather than delete it, but this API doesn't offer a way to do that.

- It's not made clear: Do objects have their __serialize() methods called when storing (and vice versa on load), or no? "They have to be serializable" is not something that can be otherwise determined.

- Status API: Uh, what are the keys? No arrays here please. Please make it an object with defined readonly properties. Please.

- You realize you're effectively adding a Memoize attribute to PHP by another name, right? Just making sure. :slight_smile:

- A property using the volatile-tracking strategy, if run in a persistent process, seems like it would never get written. That feels like a problem.

- The section on write times is rather abstract and academic, so a bit hard to follow. If I read correctly, though, it means that writing to a sub-property of an array/object on a cached property won't trigger a resave? That feels like another footgun waiting to happen.

- It's not clear if there's a way to clear an attribute-cached value other than nuking the entire volatile/persistent cache. Is there not? It feels like there should be one... Especially for "persistent," I don't want to have to nuke my entire "persistent" cache from orbit because one value got corrupted somehow.

- Defaulting off... I can see the argument for that, but that means it will be off for most users. That means I, as a library author of, say, a routing system or a DI container, cannot use it, because I have to assume most users won't have it. That kneecaps the usefulness of this feature dramatically. I would strongly recommend setting at least some default-on amount, even if it's only the minimum 8 MB, if we want this feature to actually be used. (And I can already think of a few places where I'd want to use it myself.)

- Related, what happens if they're disabled but someone tries to use this functionality? Does it operate like every read is a cache miss, or does it error? If the latter, that means any code that uses the attributes REQUIRES that the ini directives be turned on. We generally try to avoid this kind of "your code may or may not work depending on ini settings" issues. (Hello, magic_quotes!)

- What's the development experience with this? Frequently, in dev mode frameworks will disable caches. If the volatile cache just misses silently that would work there, but not for the persistent cache. How would I have a persistent route cache that is automatically rebuilt on every request during development while I'm messing with routes?

- I understand the value of keeping it simple by making it single-tenant. However, I can very easily see different 3rd party libraries wanting to make use of the cache at the same time. That poses a risk of key-space collision, though that's resolvable by a convention to use a key prefix. What it does not resolve is cases where one library wants to wipe-and-rebuild a dynamic list of keys, but some other library isn't expecting a total purge. There's a high risk of libraries stepping on each other here.

- Currently, this is just a basic key/value store. That's great for many things, but not very queryable. This is absolutely scope creep, but would there be some way to extend this (in a future RFC, I'm sure) to allow, say, a persistent memory-resident SQLite database? Currently you can write one to disk, but then you have to deal with disk permissions. A memory resident database now is request-specific, so not useful outside of testing. It would be lovely if there were some way to extend in that direction.

--Larry Garfield

2026年5月17日(日) 5:14 Larry Garfield <larry@garfieldtech.com>:

On Sat, May 16, 2026, at 10:19 AM, Go Kudo wrote:

Hi internals,

I’d like to start the discussion for a new RFC, OPcache Static Cache.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

The proposal adds an OPcache-managed shared-memory cache for explicit
userland values and for selected PHP static state. It introduces
explicit functions under the OPcache namespace (volatile_* and
persistent_*) and two attributes, #[OPcache\VolatileStatic] and
#[OPcache\PersistentStatic], that let selected static properties and
method static variables survive across requests. The feature is
disabled by default and only activates once memory is allocated through
the new INI directives.

The RFC covers the motivation, the deliberate split between the two
backends, the trust model (one PHP runtime = one trust domain; this is
not a tenant isolation boundary), and benchmarks against APCu on NTS
php-fpm and ZTS FrankenPHP. The PR is the full implementation, with
PHPT coverage summarized in the Validation section.

One thing to flag on the implementation status: the Windows build is
currently broken. I don’t have a Windows development environment
available yet — one is being arranged through work, and I’ll get the
Windows side fixed once that’s in place.

Feedback welcome.

Best Regards,
Go Kudo

Interesting! I can definitely see uses for it, and I appreciate the level of detail in the RFC.

Some thoughts, though:

  • atomic-decrement throwing if a value doesn’t exist sounds like a footgun. For something like an up/down voting widget, I could very easily see someone hitting the Down Vote button first, which would cause it to crash. Having to always check _exists() on decrement but not on increment is inconsistent and likely to confuse people.

  • Are either of these stores purged on reboot?

  • “persistent cache and returns void on success.” - No, returns null. You can’t return void. A function can have a void return type, whether it’s successful or not. Not quite the same thing.

  • Having the locks automatically self-unlock sure sounds elegant at first, but the lack of symmetry in the API feels very error prone. People will want to use it like a transaction, but since it unlocks on the first write there’s no way to make it one. It just silently unlocks if certain functions are called. But if they’re not called, there’s no way to unlock it. That’s even more of an issue in a persistent-process use case, where you could easily not hit the process-end for minutes or hours, so the lock never automatically clears.

Use case: You need to update some lookup table, so you lock the stored key, compute the new table, then write the new table. But if the compute step fails for some reason, you now have a locked value with no way to unlock it, but no new value to write to it. It’s better in many cases to leave the stale data there rather than delete it, but this API doesn’t offer a way to do that.

  • It’s not made clear: Do objects have their __serialize() methods called when storing (and vice versa on load), or no? “They have to be serializable” is not something that can be otherwise determined.

  • Status API: Uh, what are the keys? No arrays here please. Please make it an object with defined readonly properties. Please.

  • You realize you’re effectively adding a Memoize attribute to PHP by another name, right? Just making sure. :slight_smile:

  • A property using the volatile-tracking strategy, if run in a persistent process, seems like it would never get written. That feels like a problem.

  • The section on write times is rather abstract and academic, so a bit hard to follow. If I read correctly, though, it means that writing to a sub-property of an array/object on a cached property won’t trigger a resave? That feels like another footgun waiting to happen.

  • It’s not clear if there’s a way to clear an attribute-cached value other than nuking the entire volatile/persistent cache. Is there not? It feels like there should be one… Especially for “persistent,” I don’t want to have to nuke my entire “persistent” cache from orbit because one value got corrupted somehow.

  • Defaulting off… I can see the argument for that, but that means it will be off for most users. That means I, as a library author of, say, a routing system or a DI container, cannot use it, because I have to assume most users won’t have it. That kneecaps the usefulness of this feature dramatically. I would strongly recommend setting at least some default-on amount, even if it’s only the minimum 8 MB, if we want this feature to actually be used. (And I can already think of a few places where I’d want to use it myself.)

  • Related, what happens if they’re disabled but someone tries to use this functionality? Does it operate like every read is a cache miss, or does it error? If the latter, that means any code that uses the attributes REQUIRES that the ini directives be turned on. We generally try to avoid this kind of “your code may or may not work depending on ini settings” issues. (Hello, magic_quotes!)

  • What’s the development experience with this? Frequently, in dev mode frameworks will disable caches. If the volatile cache just misses silently that would work there, but not for the persistent cache. How would I have a persistent route cache that is automatically rebuilt on every request during development while I’m messing with routes?

  • I understand the value of keeping it simple by making it single-tenant. However, I can very easily see different 3rd party libraries wanting to make use of the cache at the same time. That poses a risk of key-space collision, though that’s resolvable by a convention to use a key prefix. What it does not resolve is cases where one library wants to wipe-and-rebuild a dynamic list of keys, but some other library isn’t expecting a total purge. There’s a high risk of libraries stepping on each other here.

  • Currently, this is just a basic key/value store. That’s great for many things, but not very queryable. This is absolutely scope creep, but would there be some way to extend this (in a future RFC, I’m sure) to allow, say, a persistent memory-resident SQLite database? Currently you can write one to disk, but then you have to deal with disk permissions. A memory resident database now is request-specific, so not useful outside of testing. It would be lovely if there were some way to extend in that direction.

–Larry Garfield

Hi Larry,

Thank you for the detailed feedback.

I updated the RFC and the implementation to address the concrete API and
documentation issues you pointed out.

https://wiki.php.net/rfc/opcache_static_cache

The main changes are:

  • persistent_atomic_decrement() now creates a missing key with -$step,
    matching persistent_atomic_increment().
  • volatile_lock() and persistent_lock() now have matching *_unlock()
    functions.
  • The lock APIs also accept an optional lease value, so abandoned builder
    reservations can expire even in persistent-worker environments.
  • The status APIs now return a read-only OPcache\StaticCacheInfo object
    instead of arrays.
  • The RFC now documents that both static-cache backends are scoped to the
    lifetime of the current OPcache static-cache shared-memory segment, and are
    not durable storage.
  • The wording around persistent_store() was corrected to describe the void
    return type rather than “returning void”.
  • The RFC now describes when __serialize() and __unserialize() are called, and
    that userland serialization keeps those object graphs off the fastest
    direct/shared-graph path.
  • The publication rules for VolatileStatic immediate mode, VolatileStatic tracking mode, and PersistentStatic have been expanded.
  • VolatileStatic tracking is now documented as publishing at PHP request
    shutdown, not process shutdown, even under FPM/FrankenPHP/persistent workers.
  • Attribute-backed entries can now be deleted either by loaded class name or by
    documented exact static-property/method-static keys, without clearing the
    whole backend.
  • I also reran the benchmark matrix from clean NTS FPM, NTS/ZTS CLI, and ZTS
    FrankenPHP builds, and updated the RFC tables.

Some of the broader points are open questions or design tradeoffs rather than
direct fixes.

On the default-off setting, I want to be more direct about where I actually
stand. I would prefer this feature to be default-on. Administrator opt-in
noticeably limits library adoption, and that significantly reduces the value
of the API as a portable primitive that libraries can rely on being present.

What is holding me back is the shared-hosting case. The cache is a single
shared-memory trust domain, so on a host where multiple tenants share one
OPcache segment, default-on without an isolation story could expose those
tenants to each other through the static cache. I do not yet have a design I
am confident makes default-on safe in that environment, and that is the only
reason the RFC currently ships with the feature disabled.

I would genuinely appreciate your input here. If you (or anyone on the list)
see a viable path — per-pool / per-SAPI segments, a trust-domain or namespace
mechanism enforced by the engine, a configuration model where the host opts
in per vhost rather than per server, or something I have not considered — I
would be very happy to rework the proposal around it. If we can land on a
model that is safe for shared hosting, I would gladly flip the default in
this RFC rather than defer it to a follow-up.

For disabled backends, the explicit APIs fail rather than silently pretending
to be a miss-only cache. Attribute-backed state falls back to ordinary request
local static behavior when the corresponding backend is unavailable. I can add
a short FAQ entry for this development-mode behavior if that would make the RFC
clearer.

For multi-library key collisions, the explicit cache remains a shared namespace
and applications/libraries still need key prefixes, similar to APCu. The new
attribute deletion and exact-key deletion support should reduce the need for
whole-backend clears, but this RFC does not add a separate namespace mechanism.
I left the broader trust-domain/namespace question as an Open Issue, and it is
closely related to the shared-hosting question above.

The memory-resident SQLite idea is interesting, but I think it is outside the
scope of this RFC. This proposal is intentionally a small key/value and static
state facility first.

Thanks again. The feedback helped make several parts of the API much more
explicit.

Best Regards,
Go Kudo

Am 18.05.2026, 13:00:02 schrieb Go Kudo <zeriyoshi@gmail.com>:

2026年5月17日(日) 5:14 Larry Garfield <larry@garfieldtech.com>:

On Sat, May 16, 2026, at 10:19 AM, Go Kudo wrote:

Hi internals,

I’d like to start the discussion for a new RFC, OPcache Static Cache.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

The proposal adds an OPcache-managed shared-memory cache for explicit
userland values and for selected PHP static state. It introduces
explicit functions under the OPcache namespace (volatile_* and
persistent_*) and two attributes, #[OPcache\VolatileStatic] and
#[OPcache\PersistentStatic], that let selected static properties and
method static variables survive across requests. The feature is
disabled by default and only activates once memory is allocated through
the new INI directives.

The RFC covers the motivation, the deliberate split between the two
backends, the trust model (one PHP runtime = one trust domain; this is
not a tenant isolation boundary), and benchmarks against APCu on NTS
php-fpm and ZTS FrankenPHP. The PR is the full implementation, with
PHPT coverage summarized in the Validation section.

One thing to flag on the implementation status: the Windows build is
currently broken. I don’t have a Windows development environment
available yet — one is being arranged through work, and I’ll get the
Windows side fixed once that’s in place.

Feedback welcome.

Best Regards,
Go Kudo

Interesting! I can definitely see uses for it, and I appreciate the level of detail in the RFC.

Some thoughts, though:

  • atomic-decrement throwing if a value doesn’t exist sounds like a footgun. For something like an up/down voting widget, I could very easily see someone hitting the Down Vote button first, which would cause it to crash. Having to always check _exists() on decrement but not on increment is inconsistent and likely to confuse people.

  • Are either of these stores purged on reboot?

  • “persistent cache and returns void on success.” - No, returns null. You can’t return void. A function can have a void return type, whether it’s successful or not. Not quite the same thing.

  • Having the locks automatically self-unlock sure sounds elegant at first, but the lack of symmetry in the API feels very error prone. People will want to use it like a transaction, but since it unlocks on the first write there’s no way to make it one. It just silently unlocks if certain functions are called. But if they’re not called, there’s no way to unlock it. That’s even more of an issue in a persistent-process use case, where you could easily not hit the process-end for minutes or hours, so the lock never automatically clears.

Use case: You need to update some lookup table, so you lock the stored key, compute the new table, then write the new table. But if the compute step fails for some reason, you now have a locked value with no way to unlock it, but no new value to write to it. It’s better in many cases to leave the stale data there rather than delete it, but this API doesn’t offer a way to do that.

  • It’s not made clear: Do objects have their __serialize() methods called when storing (and vice versa on load), or no? “They have to be serializable” is not something that can be otherwise determined.

  • Status API: Uh, what are the keys? No arrays here please. Please make it an object with defined readonly properties. Please.

  • You realize you’re effectively adding a Memoize attribute to PHP by another name, right? Just making sure. :slight_smile:

  • A property using the volatile-tracking strategy, if run in a persistent process, seems like it would never get written. That feels like a problem.

  • The section on write times is rather abstract and academic, so a bit hard to follow. If I read correctly, though, it means that writing to a sub-property of an array/object on a cached property won’t trigger a resave? That feels like another footgun waiting to happen.

  • It’s not clear if there’s a way to clear an attribute-cached value other than nuking the entire volatile/persistent cache. Is there not? It feels like there should be one… Especially for “persistent,” I don’t want to have to nuke my entire “persistent” cache from orbit because one value got corrupted somehow.

  • Defaulting off… I can see the argument for that, but that means it will be off for most users. That means I, as a library author of, say, a routing system or a DI container, cannot use it, because I have to assume most users won’t have it. That kneecaps the usefulness of this feature dramatically. I would strongly recommend setting at least some default-on amount, even if it’s only the minimum 8 MB, if we want this feature to actually be used. (And I can already think of a few places where I’d want to use it myself.)

  • Related, what happens if they’re disabled but someone tries to use this functionality? Does it operate like every read is a cache miss, or does it error? If the latter, that means any code that uses the attributes REQUIRES that the ini directives be turned on. We generally try to avoid this kind of “your code may or may not work depending on ini settings” issues. (Hello, magic_quotes!)

  • What’s the development experience with this? Frequently, in dev mode frameworks will disable caches. If the volatile cache just misses silently that would work there, but not for the persistent cache. How would I have a persistent route cache that is automatically rebuilt on every request during development while I’m messing with routes?

  • I understand the value of keeping it simple by making it single-tenant. However, I can very easily see different 3rd party libraries wanting to make use of the cache at the same time. That poses a risk of key-space collision, though that’s resolvable by a convention to use a key prefix. What it does not resolve is cases where one library wants to wipe-and-rebuild a dynamic list of keys, but some other library isn’t expecting a total purge. There’s a high risk of libraries stepping on each other here.

  • Currently, this is just a basic key/value store. That’s great for many things, but not very queryable. This is absolutely scope creep, but would there be some way to extend this (in a future RFC, I’m sure) to allow, say, a persistent memory-resident SQLite database? Currently you can write one to disk, but then you have to deal with disk permissions. A memory resident database now is request-specific, so not useful outside of testing. It would be lovely if there were some way to extend in that direction.

–Larry Garfield

Hi Larry,

Thank you for the detailed feedback.

I updated the RFC and the implementation to address the concrete API and
documentation issues you pointed out.

https://wiki.php.net/rfc/opcache_static_cache

The main changes are:

  • persistent_atomic_decrement() now creates a missing key with -$step,
    matching persistent_atomic_increment().
  • volatile_lock() and persistent_lock() now have matching *_unlock()
    functions.
  • The lock APIs also accept an optional lease value, so abandoned builder
    reservations can expire even in persistent-worker environments.
  • The status APIs now return a read-only OPcache\StaticCacheInfo object
    instead of arrays.
  • The RFC now documents that both static-cache backends are scoped to the
    lifetime of the current OPcache static-cache shared-memory segment, and are
    not durable storage.
  • The wording around persistent_store() was corrected to describe the void
    return type rather than “returning void”.
  • The RFC now describes when __serialize() and __unserialize() are called, and
    that userland serialization keeps those object graphs off the fastest
    direct/shared-graph path.
  • The publication rules for VolatileStatic immediate mode, VolatileStatic tracking mode, and PersistentStatic have been expanded.
  • VolatileStatic tracking is now documented as publishing at PHP request
    shutdown, not process shutdown, even under FPM/FrankenPHP/persistent workers.
  • Attribute-backed entries can now be deleted either by loaded class name or by
    documented exact static-property/method-static keys, without clearing the
    whole backend.
  • I also reran the benchmark matrix from clean NTS FPM, NTS/ZTS CLI, and ZTS
    FrankenPHP builds, and updated the RFC tables.

Some of the broader points are open questions or design tradeoffs rather than
direct fixes.

On the default-off setting, I want to be more direct about where I actually
stand. I would prefer this feature to be default-on. Administrator opt-in
noticeably limits library adoption, and that significantly reduces the value
of the API as a portable primitive that libraries can rely on being present.

What is holding me back is the shared-hosting case. The cache is a single
shared-memory trust domain, so on a host where multiple tenants share one
OPcache segment, default-on without an isolation story could expose those
tenants to each other through the static cache. I do not yet have a design I
am confident makes default-on safe in that environment, and that is the only
reason the RFC currently ships with the feature disabled.

I would genuinely appreciate your input here. If you (or anyone on the list)
see a viable path — per-pool / per-SAPI segments, a trust-domain or namespace
mechanism enforced by the engine, a configuration model where the host opts
in per vhost rather than per server, or something I have not considered — I
would be very happy to rework the proposal around it. If we can land on a
model that is safe for shared hosting, I would gladly flip the default in
this RFC rather than defer it to a follow-up.

There was a post on Reddit recently from someone modifying APCu to be FPM pool based. Maybe thats a helpful reference?

https://www.reddit.com/r/PHP/comments/1sg9rln/clevel_apcu_key_isolation_based_on_fpm_pool_names/
https://github.com/Samer-Al-iraqi/apcu-fpm-pool-isolation

For disabled backends, the explicit APIs fail rather than silently pretending
to be a miss-only cache. Attribute-backed state falls back to ordinary request
local static behavior when the corresponding backend is unavailable. I can add
a short FAQ entry for this development-mode behavior if that would make the RFC
clearer.

For multi-library key collisions, the explicit cache remains a shared namespace
and applications/libraries still need key prefixes, similar to APCu. The new
attribute deletion and exact-key deletion support should reduce the need for
whole-backend clears, but this RFC does not add a separate namespace mechanism.
I left the broader trust-domain/namespace question as an Open Issue, and it is
closely related to the shared-hosting question above.

The memory-resident SQLite idea is interesting, but I think it is outside the
scope of this RFC. This proposal is intentionally a small key/value and static
state facility first.

Thanks again. The feedback helped make several parts of the API much more
explicit.

Best Regards,
Go Kudo

On Mon, May 18, 2026, at 6:00 AM, Go Kudo wrote:

Hi Larry,

Thank you for the detailed feedback.

I updated the RFC and the implementation to address the concrete API and
documentation issues you pointed out.

PHP: rfc:opcache_static_cache

The main changes are:

- `persistent_atomic_decrement()` now creates a missing key with -$step,
  matching `persistent_atomic_increment()`.

+1

- `volatile_lock()` and `persistent_lock()` now have matching
`*_unlock()`
  functions.

I should note that this is an excellent example of where context managers and a `using` block would be helpful. :slight_smile:

- The lock APIs also accept an optional lease value, so abandoned
builder
  reservations can expire even in persistent-worker environments.

+1

- The status APIs now return a read-only `OPcache\StaticCacheInfo`
object
  instead of arrays.

+1

Though it's not entirely obvious to me which property I would need to check every single time I want to try storing to it. enabled? available? What's the fully safe read/write code pattern here?

- The RFC now documents that both static-cache backends are scoped to
the
  lifetime of the current OPcache static-cache shared-memory segment,
and are
  not durable storage.

Ah, that's a big and important distinction! What does "lifetime of the current opcace static-cache shared memory segment" mean to developers who don't read this list? :slight_smile: Does it mean "persistent" also goes away if you restart FPM/Apache/whatever? Does that mean this is all basically useless for CLI? Those should be made very clear in non-internals-speak.

So really, the distinction is more whether there's a TTL and eviction strategy, or if the eviction strategy is just "fall over and die." In that case, I'm not sure if "persistent" is even the right name for that part of the API, as it's not, well, persistent.

- The RFC now describes when `__serialize()` and `__unserialize()` are
called, and
  that userland serialization keeps those object graphs off the fastest
  direct/shared-graph path.

I want to make sure I follow here.

class Test {
  pubic string $a;
  public string $b;
}

$t = new Test();

persistent_store('t', $t);

// Later

$loadedT = persistent_fetch('t');

If Test does not have serialize/unserialize magic methods, then the object is stored "as is" and $t === $loadedT. If it does implement those methods, then it behaves like $loadedT = unserialize(serialize($t));

Is that correct? (Please clarify with examples in the RFC either way.)

- The publication rules for `VolatileStatic immediate` mode,
`VolatileStatic
  tracking` mode, and `PersistentStatic` have been expanded.
- `VolatileStatic tracking` is now documented as publishing at PHP
request
  shutdown, not process shutdown, even under FPM/FrankenPHP/persistent
workers.

+1

Some of the broader points are open questions or design tradeoffs rather than
direct fixes.

On the default-off setting, I want to be more direct about where I actually
stand. I would prefer this feature to be default-on. Administrator opt-in
noticeably limits library adoption, and that significantly reduces the value
of the API as a portable primitive that libraries can rely on being present.

What is holding me back is the shared-hosting case. The cache is a single
shared-memory trust domain, so on a host where multiple tenants share one
OPcache segment, default-on without an isolation story could expose those
tenants to each other through the static cache. I do not yet have a design I
am confident makes default-on safe in that environment, and that is the only
reason the RFC currently ships with the feature disabled.

I would genuinely appreciate your input here. If you (or anyone on the list)
see a viable path — per-pool / per-SAPI segments, a trust-domain or namespace
mechanism enforced by the engine, a configuration model where the host opts
in per vhost rather than per server, or something I have not considered — I
would be very happy to rework the proposal around it. If we can land on a
model that is safe for shared hosting, I would gladly flip the default in
this RFC rather than defer it to a follow-up.

I suppose the question I'd ask here is how much of a factor is that these days? Shared hosting was a huge part of PHP's early years, but... I don't remember the last time I actually ran on a traditional shared hosting setup with shared opcache between different tenants. How much of the market even is that these days? (I have no idea.)

For disabled backends, the explicit APIs fail rather than silently pretending
to be a miss-only cache. Attribute-backed state falls back to ordinary request
local static behavior when the corresponding backend is unavailable. I can add
a short FAQ entry for this development-mode behavior if that would make the RFC
clearer.

Yes please.

For multi-library key collisions, the explicit cache remains a shared namespace
and applications/libraries still need key prefixes, similar to APCu. The new
attribute deletion and exact-key deletion support should reduce the need for
whole-backend clears, but this RFC does not add a separate namespace mechanism.
I left the broader trust-domain/namespace question as an Open Issue, and it is
closely related to the shared-hosting question above.

I don't have a good answer other than introducing "pools", which would likely make the API much more involved. That could be good, though, I'm not sure. (Esp. if it pushes the API toward OOP rather than global functions.)

The memory-resident SQLite idea is interesting, but I think it is outside the
scope of this RFC. This proposal is intentionally a small key/value and static
state facility first.

Oh totally. It's not something that belongs in this RFC directly. I just want to see if there's a path to extend it from here in a future RFC to enable such functionality. (I have no idea what that would look like off hand.)

--Larry Garfield

2026年5月19日(火) 2:14 Larry Garfield <larry@garfieldtech.com>:

On Mon, May 18, 2026, at 6:00 AM, Go Kudo wrote:

Hi Larry,

Thank you for the detailed feedback.

I updated the RFC and the implementation to address the concrete API and
documentation issues you pointed out.

https://wiki.php.net/rfc/opcache_static_cache

The main changes are:

  • persistent_atomic_decrement() now creates a missing key with -$step,
    matching persistent_atomic_increment().

+1

  • volatile_lock() and persistent_lock() now have matching
    *_unlock()
    functions.

I should note that this is an excellent example of where context managers and a using block would be helpful. :slight_smile:

  • The lock APIs also accept an optional lease value, so abandoned
    builder
    reservations can expire even in persistent-worker environments.

+1

  • The status APIs now return a read-only OPcache\StaticCacheInfo
    object
    instead of arrays.

+1

Though it’s not entirely obvious to me which property I would need to check every single time I want to try storing to it. enabled? available? What’s the fully safe read/write code pattern here?

  • The RFC now documents that both static-cache backends are scoped to
    the
    lifetime of the current OPcache static-cache shared-memory segment,
    and are
    not durable storage.

Ah, that’s a big and important distinction! What does “lifetime of the current opcace static-cache shared memory segment” mean to developers who don’t read this list? :slight_smile: Does it mean “persistent” also goes away if you restart FPM/Apache/whatever? Does that mean this is all basically useless for CLI? Those should be made very clear in non-internals-speak.

So really, the distinction is more whether there’s a TTL and eviction strategy, or if the eviction strategy is just “fall over and die.” In that case, I’m not sure if “persistent” is even the right name for that part of the API, as it’s not, well, persistent.

  • The RFC now describes when __serialize() and __unserialize() are
    called, and
    that userland serialization keeps those object graphs off the fastest
    direct/shared-graph path.

I want to make sure I follow here.

class Test {
pubic string $a;
public string $b;
}

$t = new Test();

persistent_store(‘t’, $t);

// Later

$loadedT = persistent_fetch(‘t’);

If Test does not have serialize/unserialize magic methods, then the object is stored “as is” and $t === $loadedT. If it does implement those methods, then it behaves like $loadedT = unserialize(serialize($t));

Is that correct? (Please clarify with examples in the RFC either way.)

  • The publication rules for VolatileStatic immediate mode,
    VolatileStatic tracking mode, and PersistentStatic have been expanded.
  • VolatileStatic tracking is now documented as publishing at PHP
    request
    shutdown, not process shutdown, even under FPM/FrankenPHP/persistent
    workers.

+1

Some of the broader points are open questions or design tradeoffs rather than
direct fixes.

On the default-off setting, I want to be more direct about where I actually
stand. I would prefer this feature to be default-on. Administrator opt-in
noticeably limits library adoption, and that significantly reduces the value
of the API as a portable primitive that libraries can rely on being present.

What is holding me back is the shared-hosting case. The cache is a single
shared-memory trust domain, so on a host where multiple tenants share one
OPcache segment, default-on without an isolation story could expose those
tenants to each other through the static cache. I do not yet have a design I
am confident makes default-on safe in that environment, and that is the only
reason the RFC currently ships with the feature disabled.

I would genuinely appreciate your input here. If you (or anyone on the list)
see a viable path — per-pool / per-SAPI segments, a trust-domain or namespace
mechanism enforced by the engine, a configuration model where the host opts
in per vhost rather than per server, or something I have not considered — I
would be very happy to rework the proposal around it. If we can land on a
model that is safe for shared hosting, I would gladly flip the default in
this RFC rather than defer it to a follow-up.

I suppose the question I’d ask here is how much of a factor is that these days? Shared hosting was a huge part of PHP’s early years, but… I don’t remember the last time I actually ran on a traditional shared hosting setup with shared opcache between different tenants. How much of the market even is that these days? (I have no idea.)

For disabled backends, the explicit APIs fail rather than silently pretending
to be a miss-only cache. Attribute-backed state falls back to ordinary request
local static behavior when the corresponding backend is unavailable. I can add
a short FAQ entry for this development-mode behavior if that would make the RFC
clearer.

Yes please.

For multi-library key collisions, the explicit cache remains a shared namespace
and applications/libraries still need key prefixes, similar to APCu. The new
attribute deletion and exact-key deletion support should reduce the need for
whole-backend clears, but this RFC does not add a separate namespace mechanism.
I left the broader trust-domain/namespace question as an Open Issue, and it is
closely related to the shared-hosting question above.

I don’t have a good answer other than introducing “pools”, which would likely make the API much more involved. That could be good, though, I’m not sure. (Esp. if it pushes the API toward OOP rather than global functions.)

The memory-resident SQLite idea is interesting, but I think it is outside the
scope of this RFC. This proposal is intentionally a small key/value and static
state facility first.

Oh totally. It’s not something that belongs in this RFC directly. I just want to see if there’s a path to extend it from here in a future RFC to enable such functionality. (I have no idea what that would look like off hand.)

–Larry Garfield

Hi Larry, Benjamin,

Thanks for the further round of comments. Benjamin, your Reddit
pointer ended up being more useful than I first thought; I’ll come
back to it.

I’ll push RFC v1.2 shortly with the following changes.

Renaming PersistentStaticPinnedStatic (and the matching API

and INI directive)

You’re right that “persistent” is misleading. The data is not durable
in any disk-persistence sense; it lives only as long as the OPcache
static-cache shared-memory segment, which is destroyed when its
owner exits. “Pinned” captures the actual property: these entries
are not evictable and have no TTL, but they only exist in memory.
The new spellings are:

  • Attribute: #[OPcache\PinnedStatic]
  • API: OPcache\pinned_store(), pinned_fetch(), pinned_lock(),
    pinned_unlock(), pinned_atomic_increment(),
    pinned_atomic_decrement(), pinned_cache_info(), etc.
  • INI: opcache.static_cache.pinned_size_mb
  • Status object: OPcache\StaticCacheInfo for both backends
    (unchanged shape; only the accessor on opcache_get_status() is
    renamed)
  • Internal key prefixes: pinned_static: and pinned_static_class:
  • Exception: OPcache\StaticCacheException (unchanged)

You’re also correct that CLI use is mostly pointless for both
backends. The pinned cache and the volatile cache both die at CLI
process exit, the same way APCu and OPcache itself do today. The
feature is aimed at long-lived SAPIs (FPM, FrankenPHP, PHP embed
users, etc.) where the shared-memory owner outlives a single
request, and the RFC now states that explicitly.

Default INI values changed to 8 MiB each (was 0)

After your default-off feedback and a closer look at how PHP is
actually hosted today, I’m flipping the defaults. Both
opcache.static_cache.volatile_size_mb and
opcache.static_cache.pinned_size_mb will default to 8, which is
the documented minimum. Administrators can still disable either
backend by setting it to 0 explicitly.

The shared-hosting concern that kept me on default-off turned out to
be handled by the implementation, which I hadn’t actually checked
from that angle. The static-cache SHM is allocated through OPcache’s
existing mmap(MAP_SHARED | MAP_ANONYMOUS) handler in the FPM
master’s MINIT (or the equivalent SAPI startup point), before worker
fork. The resulting mapping is anonymous, so unrelated processes
cannot attach to it; only descendants of the FPM master that
created it inherit access. So “one FPM master = one trust domain”
is enforced by the kernel, not by the RFC.

That lines up with how PHP is hosted today:

  • VPS, dedicated, and containerised single-tenant deployments are
    already one trust domain.
  • CloudLinux PHP Selector, the de facto standard for modern shared
    hosting, gives each user their own alt-php binary, so each user
    runs under their own master with their own SHM segment.
  • Per-pod / per-tenant Kubernetes or Docker deployments isolate the
    process tree and IPC, so there is no cross-tenant SHM.
  • Managed WordPress and similar managed-application platforms run
    each site under its own PHP process or container.

The remaining edge case is the traditional cPanel default where
multiple cPanel users share one ea-php-fpm master per PHP version.
That configuration already has the same exposure for
opcache_get_status(), which is why it is conventionally disabled
there. The operational answer is the same: either set the
static-cache backends to 0 in php.ini, or migrate to per-user PHP
binaries via PHP Selector. I’ll cover this in a migration-notes /
shared-hosting section so admins running affected configurations
have a clear instruction.

Full disclosure: I haven’t touched a multi-tenant shared host in
nearly twenty years, so I’m not really in a position to judge the
current landscape. I had assumed the legacy “all tenants share one
PHP master” model was still common; it isn’t, and PHP Selector has
quietly become the standard while I wasn’t paying attention. Thanks
for pushing me to actually look it up. :slight_smile:

Benjamin, your Reddit pointer to the APCu C-level pool-isolation
patch was a useful reference point, even though the per-pool
C-level hook approach itself is out of scope for this RFC. It also
confirmed that the industry has settled on engine- or kernel-level
isolation rather than userland prefix conventions, which lines up
with the per-FPM-master story above.

StaticCacheInfo “is this backend usable right now?” pattern

Fair point. The current text doesn’t make this obvious. v1.2 will
include a polished StaticCacheInfo shape with proper documentation
and the recommended check pattern.

The field to test before any store/fetch attempt is available. It
is true only when the backend is configured, started up
successfully, and the SHM segment is initialised. enabled reports
configured non-zero memory only and does not imply usability. A code
example will accompany the property table.

__serialize() / __unserialize() behaviour

I’ll add the concrete example you sketched to the Storable Values /
serialization section. The summary in your reply matches the
implementation:

  • A class with no userland serialization hooks goes through the fast
    shared-graph / direct-restore path. A successful round trip yields
    a freshly cloned but structurally equal graph, so the loaded value
    is ==-equal to the stored one but, for object-bearing graphs,
    not ===, because each fetch returns its own independent clone of
    the request-local prototype.
  • A class that defines __serialize() / __unserialize() is taken
    off the fast path. The semantics are equivalent to
    unserialize(serialize($value)): those hooks are called, but
    outside the cache read/write lock.

Development-mode behaviour FAQ

Will add. Disabled backends report available = false on the
status object, so the recommended idiom (test available before
storing) also covers the “framework wants to bypass cache in dev”
case, since admins or dev-mode .user.ini overrides can set the
size directive to 0 to disable a backend explicitly. Attribute-backed
storage on a disabled backend falls back to ordinary request-local
static behaviour, so code annotated with #[OPcache\VolatileStatic]
or #[OPcache\PinnedStatic] keeps working on disabled-backend
hosts; the attribute becomes a no-op.

Multi-library key collisions

I’ll leave this in Open Issues for now. The class-name and exact-key
deletion paths added in v1.1 already cover the most common
“rebuild my library’s keys” need without a full clear, but a true
namespace/pool primitive is a larger design conversation than this
RFC should take on.

Memory-resident SQLite

Agreed, that’s well outside this RFC’s scope. The static-cache
backends use OPcache-managed SHM through the existing shared-memory
handler abstraction, so in principle other engine-internal
subsystems could one day reuse that infrastructure for a
memory-resident SQLite or similar. That’s a topic for a future RFC;
I’ll note it in Future Scope without committing to it.

Best regards,

Go Kudo

2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:

Hi internals,

I’d like to start the discussion for a new RFC, OPcache Static Cache.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

The proposal adds an OPcache-managed shared-memory cache for explicit userland values and for selected PHP static state. It introduces explicit functions under the OPcache namespace (volatile_* and persistent_*) and two attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that let selected static properties and method static variables survive across requests. The feature is disabled by default and only activates once memory is allocated through the new INI directives.

The RFC covers the motivation, the deliberate split between the two backends, the trust model (one PHP runtime = one trust domain; this is not a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage summarized in the Validation section.

One thing to flag on the implementation status: the Windows build is currently broken. I don’t have a Windows development environment available yet — one is being arranged through work, and I’ll get the Windows side fixed once that’s in place.

Feedback welcome.

Best Regards,
Go Kudo

Hi internals,

A couple of updates on this one.

Most of Larry’s feedback from earlier in the thread is now folded into v1.2.1: unlock/lease API, StaticCacheInfo as a readonly object, atomic_decrement creating missing keys, per-class and per-key attribute deletion, plus __serialize/__unserialize and reboot-purge documentation, among other things. Larry - if anything in there still feels off, I’d rather know now than later, so please say so.
Two practical updates since v1.1 went out:

The Windows build is now working (the original mail flagged it as broken; that’s resolved).
CI is green across the board.

I’d also really appreciate a broader review at this point. The RFC is fairly large and touches OPcache internals, the VM, and JIT-adjacent paths, so independent eyes - on the API shape, the trust-model / default-off question, the static-attribute semantics, the implementation, anything at all - would be genuinely valuable.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

Thanks!

Best regards,

Go Kudo

2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:

Hi internals,

I’d like to start the discussion for a new RFC, OPcache Static Cache.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

The proposal adds an OPcache-managed shared-memory cache for explicit userland values and for selected PHP static state. It introduces explicit functions under the OPcache namespace (volatile_* and persistent_*) and two attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that let selected static properties and method static variables survive across requests. The feature is disabled by default and only activates once memory is allocated through the new INI directives.

The RFC covers the motivation, the deliberate split between the two backends, the trust model (one PHP runtime = one trust domain; this is not a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage summarized in the Validation section.

One thing to flag on the implementation status: the Windows build is currently broken. I don’t have a Windows development environment available yet — one is being arranged through work, and I’ll get the Windows side fixed once that’s in place.

Feedback welcome.

Best Regards,
Go Kudo

Hi internals,

I made a minor clarification update to the OPcache Static Cache RFC.

The RFC is now version 1.3.0. I removed the Open Issues section and
folded the former pool/namespace item into the Security and Trust Model
and Future Scope.

The updated text clarifies that one OPcache static-cache shared-memory
segment is one trust domain, and that OPcache Static Cache is not a
tenant-isolation boundary. Deployments that run mutually untrusted
tenants under a single PHP master should disable the static-cache
backends for that master, or use separate PHP/FPM master processes,
containers, virtual machines, or equivalent OS-level isolation.

This does not change the proposed API, INI directives, default values,
implementation semantics, or voting choices.

Unless new relevant and substantive issues are raised during the remaining
cooldown period, I intend to send an Intent to Vote and proceed to the
voting phase once the cooldown has elapsed.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Discussion thread: https://externals.io/message/130912

Best regards,
Go Kudo

2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:

Hi internals,

I’d like to start the discussion for a new RFC, OPcache Static Cache.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

The proposal adds an OPcache-managed shared-memory cache for explicit userland values and for selected PHP static state. It introduces explicit functions under the OPcache namespace (volatile_* and persistent_*) and two attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that let selected static properties and method static variables survive across requests. The feature is disabled by default and only activates once memory is allocated through the new INI directives.

The RFC covers the motivation, the deliberate split between the two backends, the trust model (one PHP runtime = one trust domain; this is not a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage summarized in the Validation section.

One thing to flag on the implementation status: the Windows build is currently broken. I don’t have a Windows development environment available yet — one is being arranged through work, and I’ll get the Windows side fixed once that’s in place.

Feedback welcome.

Best Regards,
Go Kudo

Hi internals,

I plan to open voting on the OPcache Static Cache RFC no earlier than
2026-06-04 00:00 UTC, unless new relevant and substantive issues are
raised before then.

RFC:
https://wiki.php.net/rfc/opcache_static_cache

Discussion thread:
https://externals.io/message/130912

The RFC is currently at version 1.3.0. Since the initial announcement,
the main changes are:

  • the strict non-evictable backend has been renamed from “persistent” to
    “pinned”, including the API, attribute, INI directive, and status names;
  • both backends now default to 8 MiB, with 0 as the explicit opt-out value;
  • explicit cache operation failures now return false by default, with
    $throw_on_error = true available to throw OPcache\StaticCacheException;
  • the lock APIs now have explicit unlock functions and optional leases;
  • status APIs now return OPcache\StaticCacheInfo objects, with
    StaticCacheInfo::$available documented as the recommended usability check;
  • attribute-backed entries can be deleted by loaded class name or by
    documented exact static-state keys;
  • serialization-hook behavior, development-mode behavior, CLI usefulness,
    and the shared-hosting / trust-domain model have been clarified.

The proposed vote consists of four primary votes, each requiring a 2/3
majority:

  • Add the explicit volatile cache API, OPcache\volatile_*.
  • Add the explicit pinned cache API, OPcache\pinned_*.
  • Add the #[OPcache\VolatileStatic] attribute.
  • Add the #[OPcache\PinnedStatic] attribute.

I would especially appreciate final review from people familiar with OPcache,
the VM, and JIT-related code paths before voting opens. The areas where
focused feedback would still be most useful are:

  • the shared-memory lifetime and trust-domain model;
  • request-local lookup/prototype behavior and shared-graph pinning;
  • value preparation and fetch reconstruction outside cache locks;
  • static-property and method-static restore/publication semantics;
  • tracked array/object mutation hooks;
  • the JIT static-property access changes.

If there are any API, semantic, security, or implementation concerns that
should prevent the vote from opening, please raise them in this thread before
the planned voting time.

Best regards,
Go Kudo

Hi,

On Mon, Jun 1, 2026 at 8:06 AM Go Kudo <zeriyoshi@gmail.com> wrote:

2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:

Hi internals,

I’d like to start the discussion for a new RFC, OPcache Static Cache.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

The FPM shared hosting part is a problem and I don’t think this can be default and probably cannot even be optional. The reason is that we consider data leaks between pools as security issues so I don’t think we can have some feature that is actually causing a security issue. It will be a bit tricky to decide what to do if this passes in the current form because we would probably need to apply security fix and disable it. If you really want to have it enabled, we would need to explicitly state in the policy and docs that pool boundary is no longer considered as a security boundary which would be quite problematic for some shared hosting that rely on it. Maybe the solution would be to allow it only if there is one pool enabled.

Kind regards,

Jakub

2026年6月1日(月) 16:30 Jakub Zelenka <bukka@php.net>:

Hi,

On Mon, Jun 1, 2026 at 8:06 AM Go Kudo <zeriyoshi@gmail.com> wrote:

2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:

Hi internals,

I’d like to start the discussion for a new RFC, OPcache Static Cache.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

The FPM shared hosting part is a problem and I don’t think this can be default and probably cannot even be optional. The reason is that we consider data leaks between pools as security issues so I don’t think we can have some feature that is actually causing a security issue. It will be a bit tricky to decide what to do if this passes in the current form because we would probably need to apply security fix and disable it. If you really want to have it enabled, we would need to explicitly state in the policy and docs that pool boundary is no longer considered as a security boundary which would be quite problematic for some shared hosting that rely on it. Maybe the solution would be to allow it only if there is one pool enabled.

Kind regards,

Jakub

Hi Jakub, Larry, internals,

Thank you both for the feedback.

Updated RFC and Implementation:

I want to pause before moving to a vote and make sure we agree on the
security/default model first. Jakub raised this after my Intent to Vote mail,
and I think it is a substantive security concern rather than something to push
through at the last minute.

To summarize the two concerns as I understand them:

Larry’s concern is that if Static Cache is default-off, it becomes much less
useful as a portable primitive for libraries. A router, DI container, metadata
cache, or framework component cannot reasonably rely on a feature that is
usually disabled by default.
Jakub’s concern is that, in FPM, pool boundaries are treated as security
boundaries. If Static Cache creates one shared data channel across all pools
in one FPM master, then it can turn a default-on feature into a cross-pool
data leak. That is not acceptable as a PHP/FPM security model.

I agree with both points.

My previous model was “one OPcache Static Cache shared-memory segment is one
trust domain”. That was clear, but it did not solve the FPM case if the same
FPM master hosts multiple mutually untrusted pools. It effectively required
operators to disable the feature or run separate FPM masters, which is not a
satisfying answer if the feature is default-enabled.

I have therefore changed the implementation direction for FPM.

The FPM master no longer creates one global Static Cache backend shared by all
pools. Instead, it creates a separate Static Cache partition for each configured
worker pool before children are forked. Each partition owns its own volatile
and pinned backends, including the backend header, entry table, allocator state,
mutation epoch, lookup/status surface, and lock file. During child
initialization, the child activates the partition belonging to its FPM worker
pool.

The active partition is selected from the FPM worker pool that owns the child.
It is not selected from request data, the Host header, SCRIPT_FILENAME,
environment variables, cache keys, or userland input.

As a result, in FPM:

explicit Static Cache APIs operate on the active pool partition;
pinned static state is stored and restored from the active pool partition;
status reporting is pool-local;
explicit Static Cache clearing is pool-local;
request-shutdown publication is pool-local;
script invalidation and the Static Cache part of reset handling operate on
the active pool partition rather than on a global cross-pool backend.

I also added an FPM PHPT that starts two pools and verifies that:

a value stored with the volatile Static Cache API in pool alpha is not
visible from pool beta;
a #[OPcache\PinnedStatic] static value initialized in pool alpha is still
at the default value in pool beta;
after initializing both pools, returning to alpha still observes alpha’s
own values.

I plan to extend the test coverage to status reporting and clear/reset-style
operations, so the intended pool-local behavior is covered explicitly.

I have also added a new INI directive:

opcache.static_cache.allow_unsafe_runtime=0

The default is 0. When this directive is 0, Static Cache is disabled in
persistent server runtimes where PHP cannot provide a safe storage partition
comparable to the FPM per-pool partitioning described above.

In other words, SAPIs where PHP cannot identify or enforce a comparable tenant
boundary do not get Static Cache merely because the backend memory size
defaults are non-zero. An administrator who intentionally treats such a runtime
as a single trust domain can opt in explicitly by setting
opcache.static_cache.allow_unsafe_runtime=1.

With the default setting, Static Cache is available only for the SAPIs where I
think PHP can either provide the required boundary or where the SAPI is not a
traditional shared-hosting server runtime:

fpm: available by default, because Static Cache is partitioned per FPM
worker pool;
cli: available by default, because it is not a persistent shared server
runtime;
phpdbg: available by default, for the same reason as CLI;
embed: available by default, because the embedding application owns the
runtime and trust boundary. This is important for modern embedded
application-server runtimes such as FrankenPHP, where PHP is embedded into
a host runtime rather than run as a traditional shared-hosting SAPI. In that
model, making Static Cache unavailable by default would significantly reduce
its usefulness for the same library-adoption reasons Larry described.

For embed, the important caveat is that PHP itself cannot know the
multi-tenant policy of the host application. If an embedding application
intentionally hosts mutually untrusted tenants inside one persistent embedded
PHP runtime, then that embedded runtime is one Static Cache trust domain. Such
a host should disable Static Cache for those tenants, or explicitly accept the
shared-runtime trust model.

For other persistent SAPIs, such as apache2handler, litespeed, and generic
cgi-fcgi under an external process manager, Static Cache is unavailable by
default unless opcache.static_cache.allow_unsafe_runtime=1 is set.

When Static Cache is disabled by this policy, the backend behaves as
unavailable: StaticCacheInfo reports that the backend is not available,
explicit APIs fail or throw according to the existing error mode, and
attribute-backed statics retain normal request-local semantics.

This changes the proposal in a meaningful way, so I will update the RFC to
version 1.4.0 and treat it as a major RFC change. The previous Intent to Vote
is canceled; I will not open voting until the new cooldown has elapsed and a
new Intent to Vote has been sent.

The remaining question is whether this default/security model is acceptable.

I see three possible models:

A. Default-on for all SAPIs, with FPM partitioned per pool.

This is closest to Larry’s library-adoption concern. FPM is the concrete case
where PHP has a visible multi-pool boundary inside one persistent master, and
it is explicitly isolated. Other SAPIs follow the existing OPcache runtime
model: one persistent PHP runtime is one trust domain.

B. Default-on only where PHP can provide a safe boundary, or where the SAPI is
not a persistent shared-hosting server runtime; explicit administrator
opt-in otherwise.

This is the model now implemented by the new INI directive. FPM is
default-on because PHP can enforce per-pool storage partitioning. CLI and
phpdbg are not shared server runtimes. Embed is default-on because the
embedding application owns the runtime boundary, and because this is
important for embedded application-server runtimes such as FrankenPHP. For
apache2handler, litespeed/lsphp, and generic cgi-fcgi under an external
process manager, Static Cache is unavailable by default unless the
administrator explicitly opts in with
opcache.static_cache.allow_unsafe_runtime=1.

C. Default-off everywhere, with explicit opt-in.

This is the most conservative security model, but it also largely loses the
portability benefit Larry is asking for. Libraries would still need to treat
Static Cache as an optional acceleration path rather than a primitive they
can normally rely on.

I do not want to minimize legacy shared-hosting deployments. They still exist,
and PHP should not break their security assumptions. At the same time, I think
we should be careful not to optimize the default entirely around the most
conservative legacy multi-tenant model if doing so makes the feature much less
useful for the deployments and libraries that are likely to benefit from it.

The hosting landscape has changed significantly. Many modern PHP applications
are deployed on VPS, cloud instances, containers, managed application
platforms, or per-application FPM pools, and increasingly on application server
runtimes as well. In those cases, the relevant runtime is already a single
application or a single trust domain. Traditional shared hosting and
control-panel hosting are still important, but they are not the only default
deployment model we should design around.

That is why I would like to avoid C unless it is truly necessary from a
security-policy perspective. It protects the most conservative shared-runtime
case, but it also imposes a large cost on library and framework adoption. If a
library cannot assume that Static Cache is normally available, then the feature
becomes much closer to an optional site-specific optimization than to a
portable runtime primitive.

My current preference is B.

It keeps Static Cache default-enabled where PHP can provide a safe storage
boundary, especially FPM with per-pool partitions, and it requires an explicit
administrator decision where PHP cannot enforce such a boundary. It also keeps
Static Cache available by default for CLI/phpdbg and embed-based application
server runtimes such as FrankenPHP, where disabling it by default would weaken
the intended library-facing use case. This seems to address the concrete FPM
shared-hosting issue without making the feature globally default-off.

I am also open to A if Jakub and others are comfortable saying that, outside
FPM, the persistent PHP runtime/process group is the trust domain, and that
shared-hosting deployments that do not treat that runtime as trusted must
disable the feature. Conversely, if the security position is that arbitrary
Static Cache data must never be default-enabled unless PHP can enforce the
tenant boundary, then I think B is the natural fallback rather than C.

For documentation, I propose to make the trust model explicit:

In FPM, Static Cache storage is separated per FPM worker pool.
A Static Cache partition is one trust domain.
Outside FPM, Static Cache is scoped to the persistent PHP runtime/process
group provided by the SAPI. It is not automatically scoped to virtual hosts,
document roots, or OS users unless those are already separated into distinct
PHP runtimes by the SAPI or process manager.
In persistent server SAPIs where PHP cannot identify or enforce a comparable
partition boundary, Static Cache is disabled by default unless
opcache.static_cache.allow_unsafe_runtime=1 is set.
The embed SAPI is enabled by default because the embedding application owns
the runtime boundary, and because this is important for embedded
application-server runtimes such as FrankenPHP. If an embed host uses one
persistent embedded PHP runtime for mutually untrusted tenants, that host
must treat it as one Static Cache trust domain or disable Static Cache.
opcache.restrict_api and disable_functions may restrict management APIs,
but they are not the primary isolation boundary for attribute-backed Static
Cache state.
Shared-hosting operators must not enable Static Cache in a runtime shared by
mutually untrusted tenants unless that runtime is intentionally treated as a
shared trust domain.

Jakub: With the FPM per-pool partitioning and the new default-off policy for
unsafe persistent runtimes described above, would FPM default-on still be a
security concern from your point of view? Also, does the SAPI allow-list
(fpm, cli, phpdbg, and embed) seem like the right boundary for
allow_unsafe_runtime=0, given the embed/FrankenPHP use case?

Larry: Does this compromise preserve enough of the default-on behavior for the
library-adoption use case? In particular, FPM remains default-on, CLI/phpdbg
remain available, and embed remains available for application-server runtimes
such as FrankenPHP, while SAPIs without a PHP-visible tenant boundary require
an explicit administrator opt-in.

If either of you sees a better model than A/B/C, I would rather adjust the RFC
now than try to resolve this during or after voting.

Best regards,
Go Kudo

Hi, thanks for the reminder and for the RFC.

Le lun. 1 juin 2026 à 09:32, Jakub Zelenka <bukka@php.net> a écrit :

Hi,

On Mon, Jun 1, 2026 at 8:06 AM Go Kudo <zeriyoshi@gmail.com> wrote:

2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:

Hi internals,

I’d like to start the discussion for a new RFC, OPcache Static Cache.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

The FPM shared hosting part is a problem and I don’t think this can be default and probably cannot even be optional. The reason is that we consider data leaks between pools as security issues so I don’t think we can have some feature that is actually causing a security issue. It will be a bit tricky to decide what to do if this passes in the current form because we would probably need to apply security fix and disable it. If you really want to have it enabled, we would need to explicitly state in the policy and docs that pool boundary is no longer considered as a security boundary which would be quite problematic for some shared hosting that rely on it. Maybe the solution would be to allow it only if there is one pool enabled.

I agree with Jakub on this one, this should be safe by default, which means at the minimum setting the default to 0. But that’d mean we couldn’t reliably build on the expectation that people have this feature enabled, which would be a shame to me as a lib author :slight_smile: I’d rather suggest we find a way to scope per-pool (and also ini-configure per-pool). APCu doesn’t have this scope isolation, but APCu is opt-in so not really a concern there. Can’t we have per-pool SHM segments?

I also have concerns about other parts:

Attributes

I was wondering why some keys have to be reserved (FQCN and two prefixes). IIUC, this is for attributes to work. This looks like an abstraction leak to me. Then I dug the implementation a bit and it looks like a significant chunk of the complexity is for making attributes work (e.g. JIT stuff, new VM hooks, CacheStrategy::Tracking machinery). I feel like this belongs to a follow up RFC. The rest is significant enough to be discussed on its own.

Serialization / data representation

Part of why APCu is slow is that it serializes all values and puts the resulting strings in SHM, which defeats a lot of possible optimizations (interned string pointers, immutable arrays, etc). It’s nice that you’re proposing a new way to address this.

After digging in though, my main suggestion is to restrict the storage to scalars and arrays of scalars only (enums being the one exception maybe), and to leave the data representation as a separate concern: no references, no objects, no resources. If anyone wants to put a more complex PHP value in there, it becomes their responsibility to serialize() it first, or to use something like the deepclone extension I introduced a few weeks ago [1], which provides the exact same semantics as serialize but returns pure arrays of scalars. This decouples the “data representation / serialization” topic from the storage itself (opcache here, something else in my use case).

I’m proposing this because every issue I found in the object handling points back to it being a lot of surface for not much gain:

  • the fast path doesn’t handle references (neither soft = two variables pointing at the same object, nor hard = &). It doesn’t corrupt them, but it silently falls back to full serialization for the whole value as soon as one is present. So a single & or one shared object instance anywhere in a large value gets zero benefit and pays APCu-level unserialize cost on every fetch, invisibly. I’d rather reject hard refs explicitly (like resources) and represent shared object identity properly, but honestly scalars-only sidesteps the whole thing.

  • the engine already provides serialization hooks for internal objects. You add a new mechanism to clone them faster, with a fallback on the existing serialization infra. That’s interesting, but it’s yet another mechanism to maintain, while the serialization hooks themselves took many versions to get right on php-src (not a good signal with the state of the extensions ecosystem…). __serialize already returns a plain array that’s easy to traverse, so it could fit properly without a parallel protocol. Scalars-only removes the need for any of this (and with it the SPL coupling).

  • I also don’t like (in APCu too) that a call to store() can throw any kind of exception, since serialization methods can throw anything and the function just rethrows them as-is. It feels like an abstraction leak. With scalars-only there’s no serialization in the storage layer, so this goes away by construction.

So: would you consider restricting to scalars / arrays-of-scalars (not the deepclone part, just the type restriction)? It makes the storage do what it does well and keeps representation as a separate concern. It’d be best IMHO, and it deletes a large chunk of the complexity above in one move.

[1] https://github.com/symfony/php-ext-deepclone

API

27 functions is a lot, with many of them being variants of the same base API. Also, the $throw_on_error part is something we’d rather not have IMHO. What about an OOP API instead?

Here is a quick draft:

namespace OPcache;

// Values are scalars or arrays of scalars; callers serialize anything richer themselves.
//
// Error model for every method:
// misses and lock contention are normal and never throw
// get() miss -> $default ; has() miss -> false ; lock() contended -> false
// real errors always throw CacheException (no per-call flag)
// unstorable value, backend disabled/unavailable, pinned exhausted
interface CacheInterface {
public function get(string $key, mixed $default = null): mixed;
public function getMultiple(iterable $keys, mixed $default = null): array;
public function set(string $key, mixed $value): bool;
public function setMultiple(iterable $values): bool;
public function has(string $key): bool;
public function delete(string $key): bool;
public function deleteMultiple(iterable $keys): bool;
public function clear(): bool;
public function lock(string $key, int $lease = 0): bool; // lease 0 = until rshutdown
public function unlock(string $key): bool;
public function info(): CacheInfo;
}

// TTL only where it is meaningful
class VolatileCache implements CacheInterface {
public function set(string $key, mixed $value, int $ttl = 0): bool;
public function setMultiple(iterable $values, int $ttl = 0): bool;
}

// atomics only where entries never expire
class PinnedCache implements CacheInterface {
public function increment(string $key, int $step = 1): int;
public function decrement(string $key, int $step = 1): int;
}

final readonly class CacheInfo { /* [...] */ }
class CacheException extends \Exception {}

function volatile_cache(): VolatileCache {} // process-wide singleton per backend
function pinned_cache(): PinnedCache {}

API still

I read your arguments for the non-volatile API, yet I’m wondering if that makes sense at all. I understand the motivation, but is this really worth all the challenges it brings (see above: serialization, SHM management, pool scoping, ini settings, etc), when the alternative already exists and doesn’t have any of these? By alternative I mean what we do today: generate PHP code that contains the pinned values, and rely on opcache to cache them.

What we miss in the engine is the volatile API. A better APCu. But the pinned API we might not need one. The only thing is the increment/decrement part, and I’m not sure it’s enough reason to keep it. Maybe another approach could provide this in a simpler way?

Worth noting too: the per-pool / SHM / ini concerns from my first point apply entirely to pinned and only partly to volatile, so dropping pinned also shrinks the blocking security surface, not just the API.

Overall, I’d really like a better APCu to be provided by default, so thanks for pushing for this!

Cheers,
Nicolas

2026年6月1日(月) 20:36 Nicolas Grekas <nicolas.grekas+php@gmail.com>:

Hi, thanks for the reminder and for the RFC.

Le lun. 1 juin 2026 à 09:32, Jakub Zelenka <bukka@php.net> a écrit :

Hi,

On Mon, Jun 1, 2026 at 8:06 AM Go Kudo <zeriyoshi@gmail.com> wrote:

2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:

Hi internals,

I’d like to start the discussion for a new RFC, OPcache Static Cache.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

The FPM shared hosting part is a problem and I don’t think this can be default and probably cannot even be optional. The reason is that we consider data leaks between pools as security issues so I don’t think we can have some feature that is actually causing a security issue. It will be a bit tricky to decide what to do if this passes in the current form because we would probably need to apply security fix and disable it. If you really want to have it enabled, we would need to explicitly state in the policy and docs that pool boundary is no longer considered as a security boundary which would be quite problematic for some shared hosting that rely on it. Maybe the solution would be to allow it only if there is one pool enabled.

I agree with Jakub on this one, this should be safe by default, which means at the minimum setting the default to 0. But that’d mean we couldn’t reliably build on the expectation that people have this feature enabled, which would be a shame to me as a lib author :slight_smile: I’d rather suggest we find a way to scope per-pool (and also ini-configure per-pool). APCu doesn’t have this scope isolation, but APCu is opt-in so not really a concern there. Can’t we have per-pool SHM segments?

I also have concerns about other parts:

Attributes

I was wondering why some keys have to be reserved (FQCN and two prefixes). IIUC, this is for attributes to work. This looks like an abstraction leak to me. Then I dug the implementation a bit and it looks like a significant chunk of the complexity is for making attributes work (e.g. JIT stuff, new VM hooks, CacheStrategy::Tracking machinery). I feel like this belongs to a follow up RFC. The rest is significant enough to be discussed on its own.

Serialization / data representation

Part of why APCu is slow is that it serializes all values and puts the resulting strings in SHM, which defeats a lot of possible optimizations (interned string pointers, immutable arrays, etc). It’s nice that you’re proposing a new way to address this.

After digging in though, my main suggestion is to restrict the storage to scalars and arrays of scalars only (enums being the one exception maybe), and to leave the data representation as a separate concern: no references, no objects, no resources. If anyone wants to put a more complex PHP value in there, it becomes their responsibility to serialize() it first, or to use something like the deepclone extension I introduced a few weeks ago [1], which provides the exact same semantics as serialize but returns pure arrays of scalars. This decouples the “data representation / serialization” topic from the storage itself (opcache here, something else in my use case).

I’m proposing this because every issue I found in the object handling points back to it being a lot of surface for not much gain:

  • the fast path doesn’t handle references (neither soft = two variables pointing at the same object, nor hard = &). It doesn’t corrupt them, but it silently falls back to full serialization for the whole value as soon as one is present. So a single & or one shared object instance anywhere in a large value gets zero benefit and pays APCu-level unserialize cost on every fetch, invisibly. I’d rather reject hard refs explicitly (like resources) and represent shared object identity properly, but honestly scalars-only sidesteps the whole thing.

  • the engine already provides serialization hooks for internal objects. You add a new mechanism to clone them faster, with a fallback on the existing serialization infra. That’s interesting, but it’s yet another mechanism to maintain, while the serialization hooks themselves took many versions to get right on php-src (not a good signal with the state of the extensions ecosystem…). __serialize already returns a plain array that’s easy to traverse, so it could fit properly without a parallel protocol. Scalars-only removes the need for any of this (and with it the SPL coupling).

  • I also don’t like (in APCu too) that a call to store() can throw any kind of exception, since serialization methods can throw anything and the function just rethrows them as-is. It feels like an abstraction leak. With scalars-only there’s no serialization in the storage layer, so this goes away by construction.

So: would you consider restricting to scalars / arrays-of-scalars (not the deepclone part, just the type restriction)? It makes the storage do what it does well and keeps representation as a separate concern. It’d be best IMHO, and it deletes a large chunk of the complexity above in one move.

[1] https://github.com/symfony/php-ext-deepclone

API

27 functions is a lot, with many of them being variants of the same base API. Also, the $throw_on_error part is something we’d rather not have IMHO. What about an OOP API instead?

Here is a quick draft:

namespace OPcache;

// Values are scalars or arrays of scalars; callers serialize anything richer themselves.
//
// Error model for every method:
// misses and lock contention are normal and never throw
// get() miss -> $default ; has() miss -> false ; lock() contended -> false
// real errors always throw CacheException (no per-call flag)
// unstorable value, backend disabled/unavailable, pinned exhausted
interface CacheInterface {
public function get(string $key, mixed $default = null): mixed;
public function getMultiple(iterable $keys, mixed $default = null): array;
public function set(string $key, mixed $value): bool;
public function setMultiple(iterable $values): bool;
public function has(string $key): bool;
public function delete(string $key): bool;
public function deleteMultiple(iterable $keys): bool;
public function clear(): bool;
public function lock(string $key, int $lease = 0): bool; // lease 0 = until rshutdown
public function unlock(string $key): bool;
public function info(): CacheInfo;
}

// TTL only where it is meaningful
class VolatileCache implements CacheInterface {
public function set(string $key, mixed $value, int $ttl = 0): bool;
public function setMultiple(iterable $values, int $ttl = 0): bool;
}

// atomics only where entries never expire
class PinnedCache implements CacheInterface {
public function increment(string $key, int $step = 1): int;
public function decrement(string $key, int $step = 1): int;
}

final readonly class CacheInfo { /* [...] */ }
class CacheException extends \Exception {}

function volatile_cache(): VolatileCache {} // process-wide singleton per backend
function pinned_cache(): PinnedCache {}

API still

I read your arguments for the non-volatile API, yet I’m wondering if that makes sense at all. I understand the motivation, but is this really worth all the challenges it brings (see above: serialization, SHM management, pool scoping, ini settings, etc), when the alternative already exists and doesn’t have any of these? By alternative I mean what we do today: generate PHP code that contains the pinned values, and rely on opcache to cache them.

What we miss in the engine is the volatile API. A better APCu. But the pinned API we might not need one. The only thing is the increment/decrement part, and I’m not sure it’s enough reason to keep it. Maybe another approach could provide this in a simpler way?

Worth noting too: the per-pool / SHM / ini concerns from my first point apply entirely to pinned and only partly to volatile, so dropping pinned also shrinks the blocking security surface, not just the API.

Overall, I’d really like a better APCu to be provided by default, so thanks for pushing for this!

Cheers,
Nicolas

Hi Nicolas.

Thanks again for the detailed read. A fair amount of this is now addressed in 1.4.0, and you asked for a concrete OOP shape, so let me start with those and then come back to the scalars/references discussion.

Per-pool scoping (your first point)

FPM is solved in 1.4.0. There’s now one volatile and one pinned partition per worker pool, created before any worker forks (fpm_static_cache_init_main() walks fpm_worker_all_pools and calls partition_create(wp->config->name) for each pool), and each child activates its own pool’s partition in fpm_child_init() before user code runs. A value stored in one pool isn’t visible from another pool under the same master, which is the per-pool SHM segment you asked for.

Where I’d like your view is the rest. FPM is the only SAPI where PHP has a tenant boundary it can pick before request handling, so it’s the only one that gets real per-pool segments. apache2handler, LSAPI, cgi-fcgi and friends have no equivalent pre-request identity to key a partition on, so rather than invent one, 1.4.0 leaves the feature off there unless opcache.static_cache.allow_unsafe_runtime=1.

My honest read is that we don’t need to chase per-pool isolation for those SAPIs, for two reasons. The shared multi-tenant case under a non-FPM web runtime is off by default, so nobody is silently exposed. And with the 1.4.0 error model, an unavailable backend isn’t a hazard for callers: a disabled backend returns false / the default instead of throwing, so libraries that call opportunistically just degrade rather than break. So the cost of not having universal per-pool is an admin choice (leave it off, or knowingly accept runtime-wide sharing), not a correctness or safety problem.

Do you see a non-FPM deployment where default-off-plus-opt-in isn’t enough and a real per-SAPI tenant boundary would actually be needed? If so I’d rather scope it as future work for that specific SAPI than block on it, but I want to know if you think it’s load-bearing.

API shape (static classes, no $throw_on_error)

I went a slightly different way from your sketch: two classes with static methods, no instances and no shared interface.

namespace OPcache;

final class VolatileCache
{
public static function get(string $key, mixed $default = null): mixed {}
public static function getMultiple(iterable $keys, mixed $default = null): array {}
public static function set(string $key, mixed $value, int $ttl = 0): bool {}
public static function setMultiple(iterable $values, int $ttl = 0): bool {}
public static function has(string $key): bool {}
public static function delete(string $key): bool {} // exact key, or a loaded class name to drop its attribute-backed state
public static function deleteMultiple(iterable $keys): bool {}
public static function clear(): bool {}
public static function lock(string $key, int $lease = 0): bool {} // single-builder primitive; miss/contention → false
public static function unlock(string $key): bool {}
public static function info(): StaticCacheInfo {}
}

final class PinnedCache
{
public static function get(string $key, mixed $default = null): mixed {}
public static function getMultiple(iterable $keys, mixed $default = null): array {}
public static function set(string $key, mixed $value): bool {} // no TTL
public static function setMultiple(iterable $values): bool {}
public static function has(string $key): bool {}
public static function delete(string $key): bool {}
public static function deleteMultiple(iterable $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 info(): StaticCacheInfo {}
}

// StaticCacheInfo and StaticCacheException are the existing RFC types, reused as-is.

Why static rather than instances: there’s exactly one backend per partition and a handle would carry no per-instance state, volatile and pinned aren’t interchangeable (different eviction semantics, TTL vs atomics), so there’s nothing to gain from passing one around, and the differing set() arity a shared interface would impose (volatile has $ttl, pinned doesn’t) is the exact awkwardness the interface would create. Static methods sidestep all of it. Grouping them as VolatileCache:: / PinnedCache:: also answers the “many variants of the same base API” part of your 27-functions point directly, while dropping the volatile_/pinned_ prefix noise.

$throw_on_error is gone in this shape, which I agree is better. Misses and contention never throw (get returns the default, getMultiple fills per-key defaults, lock returns false); real backend failures return false / int|false; argument errors (empty key, reserved key, top-level Closure/resource, negative ttl) still raise TypeError/ValueError. StaticCacheException is then only the strict #[PinnedStatic] publication failure. The one thing the flag covered, treating a disabled backend as a hard config error, is a one-line StaticCacheInfo::available check at bootstrap, which the RFC already recommends.

This replaces the volatile_/pinned_ functions rather than adding to them. I could also add VolatileCache::remember($key, $compute, $ttl = 0) wrapping the safe lock → build-outside-the-lock → store sequence, since that’s the pattern people reach for; happy to include or drop it. If you’d still rather have instances, tell me what they’d buy and I’ll reconsider, I just couldn’t find a concrete thing here.

Scalars + arrays-of-scalars only

This is the one place I’d push back, because the measurements point the other way. The whole point of the design is to avoid the serialize-on-store / unserialize-on-fetch round trip. If storage only takes scalars and arrays of scalars, anything richer has to be serialize()'d by the caller and unserialize()'d on every read, which is the APCu cost model. So scalars-only doesn’t remove that cost, it moves it into userland and makes it mandatory for every object, including the ones the engine can already restore cheaply.

Carbon is the clearest case because it defines __serialize/__unserialize, so under a scalars-only rule it’s a forced round trip every time. From the 1.4.0 numbers on NTS php-fpm:

APCu (serialize + unserialize per fetch): ~189 us
VolatileCache::get via the Date/Time safe-direct handler: ~45 us
#[VolatileStatic] property (restored once into the slot): ~1.5 us

The ~45 us is the relevant number. Carbon keeps its own __serialize, but the Date/Time handler is registered with allows_custom_serializers = true, so a Carbon instance still takes the safe-direct copy path rather than php_var_serialize. Under scalars-only, that ~45 us goes back to the ~189 us round trip and the ~1.5 us attribute path disappears. The other object rows are the same shape: metadata object ~166 us vs ~35 us, SPL collections ~20 us vs ~5.6 us, small DateTime ~2.6 us vs ~1.1 us.

So “a lot of surface for not much gain” is the reading I’d disagree with: the gain is the 4x to ~130x, and it exists specifically because the value isn’t scalarised.

References and shared identity

You’re right about the mechanics and I won’t gloss over it. There are three store paths:

  1. shared graph: built straight into SHM, fetched with no userland code
  2. the OPcache serializer: SHM-safe binary encode, bytes copied under the lock and rebuilt after it’s released, still no userland code
  3. php_var_serialize fallback

A circular array (enter_array sees the same HashTable twice) or a shared object identity (mark_object sees the same zend_object twice) makes paths 1 and 2 bail, and the value lands on path 3. A hard reference inside object state does the same. So a value carrying one of those shapes pays path-3 cost, and today that’s silent.

Two things on that. First, path 3 is APCu parity, not worse than APCu, so it’s a floor rather than a regression. Second, the values that hit it are exactly the values scalars-only would push through serialize()/unserialize() unconditionally, so the current worst case is the normal case under your proposal.

The “invisible” part is fair, though. I’d rather make it visible (surface the chosen path in info(), or in a debug build) than ban objects, since banning them gives up the common no-ref case that’s the reason for the feature. And I have no real objection to rejecting top-level hard refs up front the way resources are rejected, if people think a silent cliff is worse than an explicit error. That’s a small change.

On “yet another mechanism vs __serialize”: the serializer hooks are still the fallback, not a replacement. The safe-direct tables only cover a fixed, engine-vetted set (Date/Time and four SPL collections), they’re registered in C by the owning extension, and nothing is exposed to userland, so it isn’t a protocol the ecosystem has to implement or get right. __serialize keeps handling everything else.

Dropping pinned

The preload + generated-array pattern is good, and the RFC treats it as the existing workaround it’s trying to formalise, not something to replace. But it has one hard limit: it only works for data you can express as a PHP literal that opcache can intern, i.e. scalars and arrays. As soon as the thing you want to keep across requests is an object graph, that route is back to a serialized string plus a per-request unserialize, which is the cost we started from.

That’s the gap pinned covers. PinnedStatic on the Carbon shape is ~1.5 us (restored once into the static slot) against ~189 us for the round trip, and there’s no preload trick that reaches that number, because preload can’t bake a live object graph into an opcode literal. I agree the increment/decrement part isn’t enough to justify pinned on its own; the object case is the reason it’s there. And with the per-pool partitions now in, pinned lives in the same isolated per-pool segment as volatile, so dropping it no longer shrinks the security surface the way it would have before 1.4.0.

Thanks again for the detailed read.

Best regards,
Go Kudo

Le lun. 1 juin 2026 à 15:22, Go Kudo <zeriyoshi@gmail.com> a écrit :

2026年6月1日(月) 20:36 Nicolas Grekas <nicolas.grekas+php@gmail.com>:

Hi, thanks for the reminder and for the RFC.

Le lun. 1 juin 2026 à 09:32, Jakub Zelenka <bukka@php.net> a écrit :

Hi,

On Mon, Jun 1, 2026 at 8:06 AM Go Kudo <zeriyoshi@gmail.com> wrote:

2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:

Hi internals,

I’d like to start the discussion for a new RFC, OPcache Static Cache.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

The FPM shared hosting part is a problem and I don’t think this can be default and probably cannot even be optional. The reason is that we consider data leaks between pools as security issues so I don’t think we can have some feature that is actually causing a security issue. It will be a bit tricky to decide what to do if this passes in the current form because we would probably need to apply security fix and disable it. If you really want to have it enabled, we would need to explicitly state in the policy and docs that pool boundary is no longer considered as a security boundary which would be quite problematic for some shared hosting that rely on it. Maybe the solution would be to allow it only if there is one pool enabled.

I agree with Jakub on this one, this should be safe by default, which means at the minimum setting the default to 0. But that’d mean we couldn’t reliably build on the expectation that people have this feature enabled, which would be a shame to me as a lib author :slight_smile: I’d rather suggest we find a way to scope per-pool (and also ini-configure per-pool). APCu doesn’t have this scope isolation, but APCu is opt-in so not really a concern there. Can’t we have per-pool SHM segments?

I also have concerns about other parts:

Attributes

I was wondering why some keys have to be reserved (FQCN and two prefixes). IIUC, this is for attributes to work. This looks like an abstraction leak to me. Then I dug the implementation a bit and it looks like a significant chunk of the complexity is for making attributes work (e.g. JIT stuff, new VM hooks, CacheStrategy::Tracking machinery). I feel like this belongs to a follow up RFC. The rest is significant enough to be discussed on its own.

Serialization / data representation

Part of why APCu is slow is that it serializes all values and puts the resulting strings in SHM, which defeats a lot of possible optimizations (interned string pointers, immutable arrays, etc). It’s nice that you’re proposing a new way to address this.

After digging in though, my main suggestion is to restrict the storage to scalars and arrays of scalars only (enums being the one exception maybe), and to leave the data representation as a separate concern: no references, no objects, no resources. If anyone wants to put a more complex PHP value in there, it becomes their responsibility to serialize() it first, or to use something like the deepclone extension I introduced a few weeks ago [1], which provides the exact same semantics as serialize but returns pure arrays of scalars. This decouples the “data representation / serialization” topic from the storage itself (opcache here, something else in my use case).

I’m proposing this because every issue I found in the object handling points back to it being a lot of surface for not much gain:

  • the fast path doesn’t handle references (neither soft = two variables pointing at the same object, nor hard = &). It doesn’t corrupt them, but it silently falls back to full serialization for the whole value as soon as one is present. So a single & or one shared object instance anywhere in a large value gets zero benefit and pays APCu-level unserialize cost on every fetch, invisibly. I’d rather reject hard refs explicitly (like resources) and represent shared object identity properly, but honestly scalars-only sidesteps the whole thing.

  • the engine already provides serialization hooks for internal objects. You add a new mechanism to clone them faster, with a fallback on the existing serialization infra. That’s interesting, but it’s yet another mechanism to maintain, while the serialization hooks themselves took many versions to get right on php-src (not a good signal with the state of the extensions ecosystem…). __serialize already returns a plain array that’s easy to traverse, so it could fit properly without a parallel protocol. Scalars-only removes the need for any of this (and with it the SPL coupling).

  • I also don’t like (in APCu too) that a call to store() can throw any kind of exception, since serialization methods can throw anything and the function just rethrows them as-is. It feels like an abstraction leak. With scalars-only there’s no serialization in the storage layer, so this goes away by construction.

So: would you consider restricting to scalars / arrays-of-scalars (not the deepclone part, just the type restriction)? It makes the storage do what it does well and keeps representation as a separate concern. It’d be best IMHO, and it deletes a large chunk of the complexity above in one move.

[1] https://github.com/symfony/php-ext-deepclone

API

27 functions is a lot, with many of them being variants of the same base API. Also, the $throw_on_error part is something we’d rather not have IMHO. What about an OOP API instead?

Here is a quick draft:

namespace OPcache;

// Values are scalars or arrays of scalars; callers serialize anything richer themselves.
//
// Error model for every method:
// misses and lock contention are normal and never throw
// get() miss -> $default ; has() miss -> false ; lock() contended -> false
// real errors always throw CacheException (no per-call flag)
// unstorable value, backend disabled/unavailable, pinned exhausted
interface CacheInterface {
public function get(string $key, mixed $default = null): mixed;
public function getMultiple(iterable $keys, mixed $default = null): array;
public function set(string $key, mixed $value): bool;
public function setMultiple(iterable $values): bool;
public function has(string $key): bool;
public function delete(string $key): bool;
public function deleteMultiple(iterable $keys): bool;
public function clear(): bool;
public function lock(string $key, int $lease = 0): bool; // lease 0 = until rshutdown
public function unlock(string $key): bool;
public function info(): CacheInfo;
}

// TTL only where it is meaningful
class VolatileCache implements CacheInterface {
public function set(string $key, mixed $value, int $ttl = 0): bool;
public function setMultiple(iterable $values, int $ttl = 0): bool;
}

// atomics only where entries never expire
class PinnedCache implements CacheInterface {
public function increment(string $key, int $step = 1): int;
public function decrement(string $key, int $step = 1): int;
}

final readonly class CacheInfo { /* [...] */ }
class CacheException extends \Exception {}

function volatile_cache(): VolatileCache {} // process-wide singleton per backend
function pinned_cache(): PinnedCache {}

API still

I read your arguments for the non-volatile API, yet I’m wondering if that makes sense at all. I understand the motivation, but is this really worth all the challenges it brings (see above: serialization, SHM management, pool scoping, ini settings, etc), when the alternative already exists and doesn’t have any of these? By alternative I mean what we do today: generate PHP code that contains the pinned values, and rely on opcache to cache them.

What we miss in the engine is the volatile API. A better APCu. But the pinned API we might not need one. The only thing is the increment/decrement part, and I’m not sure it’s enough reason to keep it. Maybe another approach could provide this in a simpler way?

Worth noting too: the per-pool / SHM / ini concerns from my first point apply entirely to pinned and only partly to volatile, so dropping pinned also shrinks the blocking security surface, not just the API.

Overall, I’d really like a better APCu to be provided by default, so thanks for pushing for this!

Cheers,
Nicolas

Hi Nicolas.

Thanks again for the detailed read. A fair amount of this is now addressed in 1.4.0, and you asked for a concrete OOP shape, so let me start with those and then come back to the scalars/references discussion.

Per-pool scoping (your first point)

FPM is solved in 1.4.0. There’s now one volatile and one pinned partition per worker pool, created before any worker forks (fpm_static_cache_init_main() walks fpm_worker_all_pools and calls partition_create(wp->config->name) for each pool), and each child activates its own pool’s partition in fpm_child_init() before user code runs. A value stored in one pool isn’t visible from another pool under the same master, which is the per-pool SHM segment you asked for.

That’s great thanks.

Where I’d like your view is the rest. FPM is the only SAPI where PHP has a tenant boundary it can pick before request handling, so it’s the only one that gets real per-pool segments. apache2handler, LSAPI, cgi-fcgi and friends have no equivalent pre-request identity to key a partition on, so rather than invent one, 1.4.0 leaves the feature off there unless opcache.static_cache.allow_unsafe_runtime=1.

IMHO “unsafe” wording is too strong: these are safe SAPIs, they just don’t have a scoping concept built in. And disabling it by default for them brings back the very concern I raised, that this won’t be a generally-available primitive authors can rely on. My take: enable it by default with a single default scope for those SAPIs, plus a clear internal API so a SAPI can define its own scoped segments. I know FrankenPHP would leverage it, and maybe others will find a way (e.g. apache2handler) to expose similar boundaries.

API shape (static classes, no $throw_on_error)

I went a slightly different way from your sketch: two classes with static methods, no instances and no shared interface.

We’ve been historically against static methods in php when plain functions provide the same.To me it’s either instances xor functions.OOP brings abstraction which brings possible IoC, that’s the benefit. I’m fine with functions also, but then the duplication is bloating the list of functions. Dunno if that’s an issue for others. I proposed just dropping the pinned variants, which kills most of that :slight_smile: And note: if we also drop object support and pinned (below), the whole thing collapses to a single volatile cache, at which point instances-vs-functions is a small call and either is fine by me.

$throw_on_error is gone in this shape, which I agree is better. Misses and contention never throw (get returns the default, getMultiple fills per-key defaults, lock returns false); real backend failures return false / int|false; argument errors (empty key, reserved key, top-level Closure/resource, negative ttl) still raise TypeError/ValueError. StaticCacheException is then only the strict #[PinnedStatic] publication failure. The one thing the flag covered, treating a disabled backend as a hard config error, is a one-line StaticCacheInfo::available check at bootstrap, which the RFC already recommends.

Thanks.

I could also add VolatileCache::remember($key, $compute, $ttl = 0) wrapping the safe lock → build-outside-the-lock → store sequence, since that’s the pattern people reach for; happy to include or drop it. If you’d still rather have instances, tell me what they’d buy and I’ll reconsider, I just couldn’t find a concrete thing here.

Personally I like this kind of transactional API.

Scalars + arrays-of-scalars only

This is the one place I’d push back, because the measurements point the other way. The whole point of the design is to avoid the serialize-on-store / unserialize-on-fetch round trip. If storage only takes scalars and arrays of scalars, anything richer has to be serialize()'d by the caller and unserialize()'d on every read, which is the APCu cost model. So scalars-only doesn’t remove that cost, it moves it into userland and makes it mandatory for every object, including the ones the engine can already restore cheaply.

Carbon is the clearest case because it defines __serialize/__unserialize, so under a scalars-only rule it’s a forced round trip every time. From the 1.4.0 numbers on NTS php-fpm:

APCu (serialize + unserialize per fetch): ~189 us
VolatileCache::get via the Date/Time safe-direct handler: ~45 us
#[VolatileStatic] property (restored once into the slot): ~1.5 us

The ~45 us is the relevant number. Carbon keeps its own __serialize, but the Date/Time handler is registered with allows_custom_serializers = true, so a Carbon instance still takes the safe-direct copy path rather than php_var_serialize. Under scalars-only, that ~45 us goes back to the ~189 us round trip and the ~1.5 us attribute path disappears. The other object rows are the same shape: metadata object ~166 us vs ~35 us, SPL collections ~20 us vs ~5.6 us, small DateTime ~2.6 us vs ~1.1 us.

So “a lot of surface for not much gain” is the reading I’d disagree with: the gain is the 4x to ~130x, and it exists specifically because the value isn’t scalarised.

I went and measured it. I built php-src from your branch (1.4.0) with ext/apcu and my ext/deepclone all compiled in, and timed a warm-cache fetch of the same value. A = APCu (serialize + unserialize). B = your native OPcache\volatile_fetch (warm, so the request-local prototype is built and it just clones). C = the array representation kept as a resident immutable value (what an opcache literal already gives you) + deepclone hydrate.
Warm, NTS, us/op:

fixture bytes | APCu | B native | C immut-array+hydrate
plain object graph (5-deep) 1.7K | 6.66 | 2.52 | 1.79
big object graph (400 objs) 476K | 2358 | 618 | 382
big config array (4k entries) 480K | 1590 | 331 | 0.045

Three things this settles for me:

  • the “Nx faster than APCu” headline is size-dependent. APCu is 2-7 us for small objects and only reaches the hundreds-of-us range at ~half-a-MB payloads, so the big multiplier is a large-object effect, not the common case.
  • C (objects-as-arrays + userland hydrate) ties or beats your native path in every warm case I tried, which is the static cache’s best case. The in-engine object machinery isn’t buying speed over a plain array representation, it’s slightly slower than it.
  • for array data, the dominant config/metadata case, an immutable array is essentially free (0.045 us): a zero-copy read with nothing to hydrate. That’s ~7000x faster than the static cache’s own array fetch, which pays an O(n) walk per read and so doesn’t even deliver the immutable-array win that opcache literals already give. The preload/generated-code path wins this one decisively, without any of the new machinery.

The other direction is telling too: in a fetch-once pattern (each key read a single time) the native path is slower than APCu, e.g. 38 us vs 7 us on the shared-identity object, because it builds a request-local prototype it never reuses. The prototype only pays off under repeated same-key fetches, which is exactly the in-request registry case I describe below.

JIT was off, but the timed work is all C-side so it barely moves the numbers.

References and shared identity

You’re right about the mechanics and I won’t gloss over it. There are three store paths:

  1. shared graph: built straight into SHM, fetched with no userland code
  2. the OPcache serializer: SHM-safe binary encode, bytes copied under the lock and rebuilt after it’s released, still no userland code
  3. php_var_serialize fallback

A circular array (enter_array sees the same HashTable twice) or a shared object identity (mark_object sees the same zend_object twice) makes paths 1 and 2 bail, and the value lands on path 3. A hard reference inside object state does the same. So a value carrying one of those shapes pays path-3 cost, and today that’s silent.

Two things on that. First, path 3 is APCu parity, not worse than APCu, so it’s a floor rather than a regression. Second, the values that hit it are exactly the values scalars-only would push through serialize()/unserialize() unconditionally, so the current worst case is the normal case under your proposal.

The “invisible” part is fair, though. I’d rather make it visible (surface the chosen path in info(), or in a debug build) than ban objects, since banning them gives up the common no-ref case that’s the reason for the feature. And I have no real objection to rejecting top-level hard refs up front the way resources are rejected, if people think a silent cliff is worse than an explicit error. That’s a small change.

“top-level hard ref” confuses me: it sounds like store($var) where $var is a reference, but the parameter isn’t by-ref, so the engine doesn’t pass the reference through anyway. A problematic hard ref is always nested, self-referencing a sub-part of the passed graph, which is exactly the shape you can’t cheaply reject up front.

But step back on what the fallback means: it triggers in cases that are hard to anticipate, so in practice this is APCu-level perf much of the time. The same object reachable from two places in a graph is not an exceptional shape.

On “yet another mechanism vs __serialize”: the serializer hooks are still the fallback, not a replacement. The safe-direct tables only cover a fixed, engine-vetted set (Date/Time and four SPL collections), they’re registered in C by the owning extension, and nothing is exposed to userland, so it isn’t a protocol the ecosystem has to implement or get right. __serialize keeps handling everything else.

Dropping pinned

The preload + generated-array pattern is good, and the RFC treats it as the existing workaround it’s trying to formalise, not something to replace. But it has one hard limit: it only works for data you can express as a PHP literal that opcache can intern, i.e. scalars and arrays. As soon as the thing you want to keep across requests is an object graph, that route is back to a serialized string plus a per-request unserialize, which is the cost we started from.

That’s the gap pinned covers. PinnedStatic on the Carbon shape is ~1.5 us (restored once into the static slot) against ~189 us for the round trip, and there’s no preload trick that reaches that number, because preload can’t bake a live object graph into an opcode literal. I agree the increment/decrement part isn’t enough to justify pinned on its own; the object case is the reason it’s there. And with the per-pool partitions now in, pinned lives in the same isolated per-pool segment as volatile, so dropping it no longer shrinks the security surface the way it would have before 1.4.0.

All these items above are variants of the same need for a solution that’d allow passing objects through the API.

I think this should be dropped. I get it can feel convenient to bring this, but not at all costs. All things discussed above come down to addressing this need, at the cost a significant abstraction leak (exceptions thrown by userland serialization hooks), duplicate functions for pinned/non-pinned, magic behavior that breaks the advertised perf benefits compared to existing solutions (the serialize fallback), etc.I worked a lot on this topic in the previous years, the symfony/var-exporter component was built for this need: conveying objects using arrays. This proves that yes, current immutable arrays are perfectly able to describe objects, provided one uses a conversion layer on top of them. (BTW you tie this to preloading, but that’s not accurate: you don’t need preload to get immutable arrays, opcache interns array literals from any cached file; preload only saves the recompile.)

The mechanism described in the RFC brings something new to me: the per-request unserialize-once, copy-many mechanism. It’s an optimization that prevents unserializing many times in the same request.This is nice, but I’m doubtful it justifies the added complexity on its own: in a single request, it’s quite easy for libraries to wrap the cache backend and keep a live registry of unserialized objects for the duration of the request.That’s already what most libs do, since that saves doing round trips to the cache backend in the same request. To me this is an already solved problem, with a better existing solution: a request registry returns the same instance with zero copy, where the engine hands back N independent clones. And for the read-only config/metadata that’s the actual workload here, you want that shared instance, not isolated copies, so the isolation the copy buys you solves a problem this use case doesn’t have.

I remain unconvinced about this object-transmission machinery. Dunno what others think about it.

So where I land, concretely: I’d happily vote yes on a focused “better APCu”: a volatile backend, scalars and arrays of scalars, per-pool segments (with a default scope + an internal API for the other SAPIs), and the functions-or-instances API with a remember() helper. Objects left to a userland hydration layer; attributes and pinned to a later RFC if someone still wants them once this ships. That’s a primitive I could build on, and it sidesteps every hard problem in this thread.

Nicolas

See also Tyson’s php-immutable_cache:

https://github.com/TysonAndre/immutable_cache-pecl

Related disucssions:
https://github.com/krakjoe/apcu/issues/175
https://github.com/krakjoe/apcu/issues/453
https://github.com/krakjoe/apcu/issues/323


Timo Tijhof,
Wikimedia Foundation.
https://timotijhof.net/

2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:

Hi internals,

I’d like to start the discussion for a new RFC, OPcache Static Cache.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

The proposal adds an OPcache-managed shared-memory cache for explicit userland values and for selected PHP static state. It introduces explicit functions under the OPcache namespace (volatile_* and persistent_*) and two attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that let selected static properties and method static variables survive across requests. The feature is disabled by default and only activates once memory is allocated through the new INI directives.

The RFC covers the motivation, the deliberate split between the two backends, the trust model (one PHP runtime = one trust domain; this is not a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage summarized in the Validation section.

One thing to flag on the implementation status: the Windows build is currently broken. I don’t have a Windows development environment available yet — one is being arranged through work, and I’ll get the Windows side fixed once that’s in place.

Feedback welcome.

Best Regards,
Go Kudo

Hi Nicolas, Jakub, Timo, Larry

I update RFC and Implementation:
RFC: https://wiki.php.net/rfc/opcache_static_cache
PR: https://github.com/php/php-src/pull/22052

I’m folding replies to all three of you into one message, since the
threads overlap. Most of it answers Nicolas’s measurements; further down
there is a section for Jakub’s FPM pool-isolation concern and a short note
for Timo’s pointer to prior art.

Nicolas, thank you for building my branch and running your own A/B/C
measurements. That moved the discussion onto concrete ground, and I
appreciate it.

Since your review I have pushed a revised branch and bumped the RFC to
2.0.0. The API changes discussed below are in it (the SAPI opt-in model,
and getCacheStoreType() for storage-path visibility), and the object
workloads you flagged are now substantially faster: native now beats the
deepclone path on every nested case I tried. Details and numbers follow.

I agree with most of your points. I’ll go through them in order, concede
the ones where you are right, and try to narrow what is left. I think it
comes down to one question: whether a userland array-hydration layer is an
acceptable replacement for engine-level object storage. Most of the rest I
can give you.

The resulting public API

For reference, here is the shape the explicit API settled into, summarised
from the stub:

namespace OPcache;

// Explicit cache: two final classes, static methods only, no instances.
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;
}

// PinnedCache is the same set, except set()/setMultiple() take no $ttl,
// plus two atomic counters:
final class PinnedCache
{
// get/getMultiple/set/setMultiple/has/delete/deleteMultiple/clear/
// lock/unlock/getCacheStoreType/info -- as above
public static function increment(string $key, int $step = 1): int|false;
public static function decrement(string $key, int $step = 1): int|false;
}

// getCacheStoreType() reports how a value is stored, without decoding it:
enum CacheStoreType
{
case NotFound; // no entry for the key/property
case Scalar; // stored inline
case SharedGraph; // zero-copy graph laid out in SHM (the fast path)
case OPcacheSerialized; // OPcache binary serializer (SHM-safe, no userland)
case PHPSerialized; // php_var_serialize() last resort
}

// Declarative static state, over the same storage:
#[Attribute] final class VolatileStatic {
public function __construct(int $ttl = 0, CacheStrategy $strategy = CacheStrategy::Immediate);
}
#[Attribute] final class PinnedStatic {}
enum CacheStrategy: int { case Immediate = 0; case Tracking = 1; }

// Status object and the single exception type:
final readonly class StaticCacheInfo { /* enabled, available, configured_memory, entry_count, ... */ }
class StaticCacheException extends \Exception {}

Two final classes with static methods, no instances and no shared
interface. Misses and contention return the default or false; genuine
backend failures return false (or int|false for the atomic counters);
Closure and resource values are rejected with a TypeError; and
StaticCacheException is reserved for strict #[OPcache\PinnedStatic]
publication.

SAPI availability: the unsafe flag is gone, opt-in instead

these are safe SAPIs, they just don’t have a scoping concept built in
[…] enable it by default with a single default scope for those SAPIs,
plus a clear internal API so a SAPI can define its own scoped segments

I implemented it the way you suggested. There is no longer an
opcache.static_cache.allow_unsafe_runtime directive and no SAPI-name
allowlist in the engine. Availability is opt-in: a SAPI, or an embedder,
calls a small internal C API, zend_opcache_static_cache_opt_in(), before
request handling to enable Static Cache for its runtime. That call is the
runtime declaring that a trust/storage boundary holds for the lifetime of
the shared-memory owner.

The bundled fpm, cli, cli-server and phpdbg SAPIs call it at
startup, so they are available by default. The difference from before is the
mechanism: instead of the engine guessing from the SAPI name and offering an
“unsafe” override, each runtime states that it owns a boundary. A runtime
with a real per-tenant boundary scopes it with the partition API
(zend_opcache_static_cache_partition_create / _activate, which fpm
already uses per pool). A runtime without one, such as a shared multi-tenant
web SAPI with no pre-request identity, never opts in and stays unavailable,
with nothing left to misconfigure.

The embed SAPI does not auto-opt-in, on purpose. The embedding application
owns the runtime and its trust boundary, so it opts in from its own startup
code. That keeps the rule consistent for every embedder, including one that
registers its own SAPI module instead of reusing the bundled embed one.
FrankenPHP does exactly that, so it opts in with the same one-line call (or a
scoped partition when it isolates per worker); there is no embed
special-case that covers php_embed users but silently misses FrankenPHP.

That is your internal-API point, and it removes the naming question by
deleting the flag entirely. The full ext/opcache suite passes with the
directive gone.

API shape: remember()

I could also add VolatileCache::remember($key, $compute, $ttl = 0)
wrapping the safe lock → build-outside-the-lock → store sequence

I would rather not add this one. remember() takes a callable, and to
actually prevent a stampede it has to hold the entry lock across the call to
$compute(). That means running arbitrary userland PHP while holding a
cross-process SHM lock. The callable can run unbounded, throw, fork, or
re-enter the cache, and a re-entrant lock() on the same key (or a key in
the same lock stripe) while the lock is held is a deadlock. The lease bounds
the duration, but not the re-entrancy and not the exception path.

Not holding the lock while computing gives no stampede protection at all; it
is then just sugar over get()-then-set() that looks atomic, which is
worse than not having it.

Since I already expose lock()/unlock() with a lease, userland can do the
safe thing itself, with the compute step outside any engine lock:

if (!VolatileCache::lock($key, $lease)) {
return VolatileCache::get($key, $default);
}
try {
$value = $compute(); // runs outside the engine lock
VolatileCache::set($key, $value, $ttl);
return $value;
} finally {
VolatileCache::unlock($key);
}

That keeps the closure’s execution, its scope, and any exception it throws in
userland, never inside the engine’s critical section. I would rather document
this recipe than move userland execution into the primitive. If you see a
safe construction I have missed, I will reconsider.

References and the silent fallback

I’d rather make it visible (surface the chosen path in info(), or in a
debug build) than ban objects

Agreed, and that is implemented: visibility, not a ban. There is a new
introspection method on both cache classes:

VolatileCache::getCacheStoreType(string $key_or_property, ?string $class_name = null): OPcache\CacheStoreType
PinnedCache::getCacheStoreType(string $key_or_property, ?string $class_name = null): OPcache\CacheStoreType

It returns an OPcache\CacheStoreType enum (NotFound, Scalar,
SharedGraph, OPcacheSerialized, PHPSerialized), so you can see per key
which path a value took, without decoding it, in any build rather than only a
debug one. Passing $class_name inspects the attribute-backed
static-property storage for that class instead of an explicit key. A value
that fell back to serialization is now one call away from being observable.

The enum also pins down a correction. The first fallback off the shared graph
is not php_var_serialize but the OPcache binary serializer, which is
SHM-safe and runs no userland code. That is why getCacheStoreType reports
OPcacheSerialized and PHPSerialized as separate cases; php_var_serialize
is the last resort, not the first. So “bail == APCu parity” understates the
middle tier, though your underlying point holds: even that tier is slower than
the fast path and should be visible.

no real objection to rejecting top-level hard refs up front […]
“top-level hard ref” confuses me

You are right to be confused, and I will retract the phrase; it is a no-op.
store($key, $value) takes $value by value, so the engine dereferences any
top-level reference (ZVAL_DEREF) before storage ever sees it. A top-level
hard ref cannot reach the storage layer as a reference. The case that matters
is a nested reference, a & inside an array element or object property, and
that cannot be rejected cheaply up front: detecting it requires walking the
whole graph, which is the walk the shared-graph builder already does. So the
honest answer for nested refs is the visibility above (the value reports the
serialize path), not an up-front rejection.

Scalars and arrays-of-scalars only

This is where the discussion helped most. I argued before that scalars-only
gave up a real win; you pushed back with measurements; so I built your setup
and measured it properly, including the large nested workloads that are the
actual case for a cache. You were right that native was losing. That sent me
into the implementation, and I found the cause and fixed it. The path is
worth setting out.

Two of your framings I agree with up front:

  1. For array-of-scalars config/metadata, an immutable interned array is
    essentially free, and the cache should not claim to beat it.
  2. The “Nx faster than APCu” headline is size-dependent; APCu is only a few
    microseconds for small payloads.

(a) The config array

an immutable array is essentially free (0.045 us) […] the static
cache’s own array fetch, which pays an O(n) walk per read and so doesn’t
even deliver the immutable-array win that opcache literals already give

You are structurally right, and I have fixed it. Two facts first. I could not
reproduce 331 us: a pure-scalar 4k-entry array fetches in about 7 us, scaling
at roughly 1.7 ns/entry, and the decode itself was already zero-copy (a
scalar array is stored once as IS_ARRAY_IMMUTABLE and returned as
ZVAL_ARR() straight into SHM). The O(n) you felt was one layer up: every
warm fetch re-walked the array in value_needs_request_local_clone() to
decide whether it needed a deep clone, when that answer is fixed at store
time. I removed that walk for shared-graph values (the same change as in
(c)); the 4k fetch is now about 0.64 us and flat in the entry count.

It is still not the 0.014 us of a resident literal read, and I am not
claiming it should be. For read-only scalar config the preload/literal path
wins, and that is fine. It is a separate matter from objects.

(b) Objects: I measured your A/B/C, found native losing, and chased why

I built this branch with APCu master and your deepclone, all NTS, JIT off,
timing warm fetches where C rebuilds the same isolated object graph B returns
(resident dehydrated array plus deepclone_from_array). As you said, native
lost, and worse as the graph grew. us/op:

array of nested ORM entities objects A apcu B native C hydrate
1000 1800 799 501
2000 4171 1903 1043
object tree 8191 1582 1736 498
9841 1928 1836 523

Two things you were right about that I had wrong: deepclone_to_array /
deepclone_from_array are generic (no per-class hydrator to charge for), and
C hands back the same isolated objects B does. So this was a real loss, not a
measurement artifact.

The cause was structural, but not where I first guessed. The warm fetch kept
a request-local prototype of the materialized graph and deep-cloned it on
every repeat fetch, and for an object graph that clone is slower than decoding
the compact SHM layout again. A shared graph never holds shared identity or
cycles, so each decode is already an independent copy; the prototype was pure
overhead. On top of that the decoder re-resolved the class
(zend_lookup_class) for every object, and the builder stored a separate copy
of each repeated class and property name.

(c) The fix

Three changes, all behind the existing API, with no visible behaviour or
format change:

  • Skip the request-local prototype for shared-graph values and decode from
    SHM on each fetch. (This also removes the O(n) array walk in (a).)
  • Deduplicate equal strings within a payload at build time, so a class or
    property name repeated across thousands of objects is stored once.
  • Memoize the resolved class per (buffer, offset) during a decode, so a
    homogeneous graph resolves its class once, not once per node.

Same A/B/C after the change, NTS, JIT off, us/op:

array of nested ORM entities objects A apcu B native C hydrate
1000 1781 357 492
2000 3868 721 1036
object tree 8191 1565 462 485
9841 1830 499 513

Native now beats deepclone on every nested workload I tried: about 1.4x on
the 2000-entity array, and the deep trees that lost 3.5x now win. The
400-object case went from 72 to 23 us. The full ext/opcache suite passes,
plus new regression tests, on NTS and ZTS.

To make this reproducible on your terms, I added a deepclone backend to my own
HTTP benchmark harness (dehydrate with deepclone_to_array(), keep the array
in the volatile cache, rehydrate with deepclone_from_array() on each fetch)
and re-ran vote_read_long under the published conditions (php-fpm + nginx
NTS and FrankenPHP ZTS, 20 iterations / 3 warmup / 3000 ops, JIT off). The
APCu baselines match the published table within about 2%, so the runtimes are
comparable. native vs deepclone, mean us/op (NTS):

workload APCu native deepclone
route_table_read 161.2 0.90 0.91 (array: tie)
large_array 90.9 0.88 0.88 (array: tie)
metadata_object_read 185.3 1.12 1.32 (native)
metadata_object_mutate 162.4 1.03 1.19 (native)
safe_direct_object 2.5 1.22 3.03 (native; deepclone slower than APCu)
carbon_datetime_object 185.4 46.0 166.3 (native, ~3.6x)
spl_collection_object 21.0 5.48 1.89 (deepclone)

So under the RFC’s own methodology native is faster than the deepclone path on
every object workload except SPL collections, and ties on arrays. The SPL case
is the one real win for deepclone, and it is specific: those classes go through
the safe-direct serialized path, whose per-fetch copy handler is heavier than
rebuilding from a flat array. I have noted it in the RFC as a concrete
follow-up (a tighter SPL copy handler); it does not change the overall picture.
The updated tables are in the RFC.

Honest edges remain: for a tiny object deepclone’s tight path is a hair faster
(sub-microsecond), and for read-only scalar config a resident literal still
wins outright, as in (a). But for the workload this feature is actually for,
large nested object graphs from a database, in-engine storage is now the
faster option.

(d) Not just performance

This does not rest on performance alone. Object support is also useful for
being built in and generic (no third-party extension, nothing to pre-generate)
and for being one primitive: the store side and the runtime cross-worker
sharing live in the same place, instead of “cache the array” plus “hydrate in
userland” wired together by every library. And the safe-direct registry is not
a userland protocol: a plain user object with no magic and no cycles or refs
takes the fast path automatically via can_restore_direct(), and the C-only
registry only covers a few internal classes whose state the generic path
cannot read. Keeping objects imposes nothing on the ecosystem.

Dropping pinned (and the attributes)

PinnedStatic on the Carbon shape is ~1.5 us […] there’s no preload
trick that reaches that number, because preload can’t bake a live object
graph into an opcode literal

Pinned is the one place a live-object representation still wins clearly, for a
reason the volatile numbers above do not capture. Pinned (and
#[PinnedStatic]) materialize the graph once per worker; after that it is a
plain static read on every subsequent request in that worker, near zero per
request. The hydration approach pays its hydrate cost on every request instead.
preload cannot reach this either: it can only intern scalar and array
literals, not bake a live object graph into an opcode literal.

The caveat is that this holds for read-only / immutable shared state, where
keeping one live instance across requests is correct; a mutable shared instance
would leak between requests. But that is a real and common case: a compiled DI
container, a routing table, config value objects. Your request-registry counter
rebuilds per request from the cache, so it does not reach the per-worker
amortization, and for the read-only data where it would help, pinned already
does it with less per-request cost.

The attributes are the ergonomic surface over that same mechanism, so I would
keep them in this RFC rather than split them out. They add no new storage
model; they remove the explicit store/fetch boilerplate for the static-state
case.

Where this leaves us

What is already done or committed: the SAPI opt-in model (the
allow_unsafe_runtime flag and the SAPI allowlist are gone, replaced by the
internal opt-in/partition API); the error model; storage-path visibility via
getCacheStoreType(); dropping the “top-level ref” idea; the config-array fix
(skipping the request-local prototype for shared graphs, which removes the
per-fetch array walk so a warm scalar-array fetch is zero-copy); and the
large-nested object path from (d), with numbers on this same A/B/C. I am
declining remember(), for the lock-safety reason above.

On the central question I went where the measurements led. You were right that
native lost as shipped; I found why (a request-local prototype clone slower
than re-decoding, plus per-object class lookups and duplicated strings), fixed
all three, and native now beats your deepclone path on the nested object
workloads, with the full opcache suite and new regression tests passing on NTS
and ZTS. For tiny objects deepclone is still a hair ahead, and for read-only
scalar config a resident literal still wins; I concede both.

So I do think in-engine object storage earns its place now, on performance and
on being a built-in, generic, single primitive (and on pinned’s per-worker
amortization for read-only state). But if the body still prefers a focused
better-APCu plus a core hydration primitive, that is an outcome I can support;
the capability matters to me more than where it sits, and the work above
transfers either way.

The revised branch is pushed and the harness is published, so you can check
the numbers directly; I will also post the full before/after A/B/C here. If you
have a methodology you would prefer, I will run that too.

Thanks again. This got much sharper because you measured it, and it sent me to
a fix I would not have found otherwise.

Jakub: the FPM pool boundary is preserved

The FPM shared hosting part is a problem […] we consider data leaks
between pools as security issues […] Maybe the solution would be to
allow it only if there is one pool enabled.

This is the concern I most wanted to get right, and I think the implementation
answers it without the single-pool restriction. Static Cache is not one cache
shared across pools. FPM creates a separate partition per worker pool in the
master, before any worker forks; each partition owns its own volatile and
pinned shared-memory backend, and each worker activates only its own pool’s
partition during child initialization, before user code runs. Every cache API,
status call, clear, and the Static Cache part of opcache_reset() operates on
the active pool’s partition. There is no API path from one pool to another
pool’s data, so the pool boundary stays a security boundary and no policy
change is needed. If a pool’s partition fails to start it gets no Static Cache;
it never falls back to a shared one.

One honest caveat, for the record: the per-pool segments are anonymous shared
mappings created in the master before fork, so a worker inherits every pool’s
segment in its address space even though it can only ever address its own
pool’s partition. That is the same exposure model as the main OPcache SHM,
which is already shared across pools today; the Static Cache is in fact more
isolated, because it is logically partitioned per pool where the script cache
is not. The data-leak-through-the-feature case you raised, one pool reading
another’s cached values through the API, does not exist in this design. If on
top of that we want address-space isolation, so a worker cannot even see
another pool’s bytes, that is a worthwhile hardening (per-pool named segments
mapped only in that pool’s children, or unmapping the others post-fork), and I
am happy to do it as a follow-up if you consider it in scope.

Your single-pool suggestion would also work, but per-pool partitions keep the
feature usable for the multi-pool shared-hosting setups where a single-cache
design would otherwise be unacceptable.

Timo: thanks for the immutable_cache pointer

See also Tyson’s php-immutable_cache […] related APCu discussions

Thank you. Tyson told me about immutable_cache himself a while ago, and it
shaped my thinking here. I built an internal extension along the same lines,
colopl_cache, an APCu-style drop-in for immutable values. What that work
showed me is that the parts that matter most for this use case (OPcache
compatibility, behaviour under a JIT-heavy workload, and the Zend VM
intervention needed for static-state caching) are very hard to get right as an
ordinary extension. That is why I brought this to OPcache as an RFC instead of
shipping another extension: it needs cooperation from the engine, the VM, and a
few internal classes that an extension cannot coordinate cleanly. So the prior
art is genuinely appreciated; it is part of how I arrived here.

Best regards,
Go Kudo

2026年6月2日(火) 23:53 Alexandru Pătrănescu <drealecs@gmail.com>:

Hi Go Kudo,

I am writing to you privately regarding your recent emails on the internals thread.

I appreciate the use of AI tools to ensure clear and professional communication; I often use them myself when working in other languages. However, I’ve found that the replies have become quite long, making it difficult to stay focused and read them entirely.

If you are using an AI to generate these responses, would you consider asking it to be more concise or to provide shorter summaries?
Or maybe better, try using it primarily for translation and refinement of your own notes rather than letting it generate the full bulk of the content.

I apologize if I am mistaken and you aren’t using AI for these replies, but I wanted to share this feedback in case it helps.

Best regards,
Alex

On Tue, Jun 2, 2026 at 4:22 PM Go Kudo <zeriyoshi@gmail.com> wrote:

2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:

Hi internals,

I’d like to start the discussion for a new RFC, OPcache Static Cache.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

The proposal adds an OPcache-managed shared-memory cache for explicit userland values and for selected PHP static state. It introduces explicit functions under the OPcache namespace (volatile_* and persistent_*) and two attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that let selected static properties and method static variables survive across requests. The feature is disabled by default and only activates once memory is allocated through the new INI directives.

The RFC covers the motivation, the deliberate split between the two backends, the trust model (one PHP runtime = one trust domain; this is not a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage summarized in the Validation section.

One thing to flag on the implementation status: the Windows build is currently broken. I don’t have a Windows development environment available yet — one is being arranged through work, and I’ll get the Windows side fixed once that’s in place.

Feedback welcome.

Best Regards,
Go Kudo

Hi Nicolas, Jakub, Timo, Larry

I update RFC and Implementation:
RFC: https://wiki.php.net/rfc/opcache_static_cache
PR: https://github.com/php/php-src/pull/22052

I’m folding replies to all three of you into one message, since the
threads overlap. Most of it answers Nicolas’s measurements; further down
there is a section for Jakub’s FPM pool-isolation concern and a short note
for Timo’s pointer to prior art.

Nicolas, thank you for building my branch and running your own A/B/C
measurements. That moved the discussion onto concrete ground, and I
appreciate it.

Since your review I have pushed a revised branch and bumped the RFC to
2.0.0. The API changes discussed below are in it (the SAPI opt-in model,
and getCacheStoreType() for storage-path visibility), and the object
workloads you flagged are now substantially faster: native now beats the
deepclone path on every nested case I tried. Details and numbers follow.

I agree with most of your points. I’ll go through them in order, concede
the ones where you are right, and try to narrow what is left. I think it
comes down to one question: whether a userland array-hydration layer is an
acceptable replacement for engine-level object storage. Most of the rest I
can give you.

The resulting public API

For reference, here is the shape the explicit API settled into, summarised
from the stub:

namespace OPcache;

// Explicit cache: two final classes, static methods only, no instances.
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;
}

// PinnedCache is the same set, except set()/setMultiple() take no $ttl,
// plus two atomic counters:
final class PinnedCache
{
// get/getMultiple/set/setMultiple/has/delete/deleteMultiple/clear/
// lock/unlock/getCacheStoreType/info -- as above
public static function increment(string $key, int $step = 1): int|false;
public static function decrement(string $key, int $step = 1): int|false;
}

// getCacheStoreType() reports how a value is stored, without decoding it:
enum CacheStoreType
{
case NotFound; // no entry for the key/property
case Scalar; // stored inline
case SharedGraph; // zero-copy graph laid out in SHM (the fast path)
case OPcacheSerialized; // OPcache binary serializer (SHM-safe, no userland)
case PHPSerialized; // php_var_serialize() last resort
}

// Declarative static state, over the same storage:
#[Attribute] final class VolatileStatic {
public function __construct(int $ttl = 0, CacheStrategy $strategy = CacheStrategy::Immediate);
}
#[Attribute] final class PinnedStatic {}
enum CacheStrategy: int { case Immediate = 0; case Tracking = 1; }

// Status object and the single exception type:
final readonly class StaticCacheInfo { /* enabled, available, configured_memory, entry_count, ... */ }
class StaticCacheException extends \Exception {}

Two final classes with static methods, no instances and no shared
interface. Misses and contention return the default or false; genuine
backend failures return false (or int|false for the atomic counters);
Closure and resource values are rejected with a TypeError; and
StaticCacheException is reserved for strict #[OPcache\PinnedStatic]
publication.

SAPI availability: the unsafe flag is gone, opt-in instead

these are safe SAPIs, they just don’t have a scoping concept built in
[…] enable it by default with a single default scope for those SAPIs,
plus a clear internal API so a SAPI can define its own scoped segments

I implemented it the way you suggested. There is no longer an
opcache.static_cache.allow_unsafe_runtime directive and no SAPI-name
allowlist in the engine. Availability is opt-in: a SAPI, or an embedder,
calls a small internal C API, zend_opcache_static_cache_opt_in(), before
request handling to enable Static Cache for its runtime. That call is the
runtime declaring that a trust/storage boundary holds for the lifetime of
the shared-memory owner.

The bundled fpm, cli, cli-server and phpdbg SAPIs call it at
startup, so they are available by default. The difference from before is the
mechanism: instead of the engine guessing from the SAPI name and offering an
“unsafe” override, each runtime states that it owns a boundary. A runtime
with a real per-tenant boundary scopes it with the partition API
(zend_opcache_static_cache_partition_create / _activate, which fpm
already uses per pool). A runtime without one, such as a shared multi-tenant
web SAPI with no pre-request identity, never opts in and stays unavailable,
with nothing left to misconfigure.

The embed SAPI does not auto-opt-in, on purpose. The embedding application
owns the runtime and its trust boundary, so it opts in from its own startup
code. That keeps the rule consistent for every embedder, including one that
registers its own SAPI module instead of reusing the bundled embed one.
FrankenPHP does exactly that, so it opts in with the same one-line call (or a
scoped partition when it isolates per worker); there is no embed
special-case that covers php_embed users but silently misses FrankenPHP.

That is your internal-API point, and it removes the naming question by
deleting the flag entirely. The full ext/opcache suite passes with the
directive gone.

API shape: remember()

I could also add VolatileCache::remember($key, $compute, $ttl = 0)
wrapping the safe lock → build-outside-the-lock → store sequence

I would rather not add this one. remember() takes a callable, and to
actually prevent a stampede it has to hold the entry lock across the call to
$compute(). That means running arbitrary userland PHP while holding a
cross-process SHM lock. The callable can run unbounded, throw, fork, or
re-enter the cache, and a re-entrant lock() on the same key (or a key in
the same lock stripe) while the lock is held is a deadlock. The lease bounds
the duration, but not the re-entrancy and not the exception path.

Not holding the lock while computing gives no stampede protection at all; it
is then just sugar over get()-then-set() that looks atomic, which is
worse than not having it.

Since I already expose lock()/unlock() with a lease, userland can do the
safe thing itself, with the compute step outside any engine lock:

if (!VolatileCache::lock($key, $lease)) {
return VolatileCache::get($key, $default);
}
try {
$value = $compute(); // runs outside the engine lock
VolatileCache::set($key, $value, $ttl);
return $value;
} finally {
VolatileCache::unlock($key);
}

That keeps the closure’s execution, its scope, and any exception it throws in
userland, never inside the engine’s critical section. I would rather document
this recipe than move userland execution into the primitive. If you see a
safe construction I have missed, I will reconsider.

References and the silent fallback

I’d rather make it visible (surface the chosen path in info(), or in a
debug build) than ban objects

Agreed, and that is implemented: visibility, not a ban. There is a new
introspection method on both cache classes:

VolatileCache::getCacheStoreType(string $key_or_property, ?string $class_name = null): OPcache\CacheStoreType
PinnedCache::getCacheStoreType(string $key_or_property, ?string $class_name = null): OPcache\CacheStoreType

It returns an OPcache\CacheStoreType enum (NotFound, Scalar,
SharedGraph, OPcacheSerialized, PHPSerialized), so you can see per key
which path a value took, without decoding it, in any build rather than only a
debug one. Passing $class_name inspects the attribute-backed
static-property storage for that class instead of an explicit key. A value
that fell back to serialization is now one call away from being observable.

The enum also pins down a correction. The first fallback off the shared graph
is not php_var_serialize but the OPcache binary serializer, which is
SHM-safe and runs no userland code. That is why getCacheStoreType reports
OPcacheSerialized and PHPSerialized as separate cases; php_var_serialize
is the last resort, not the first. So “bail == APCu parity” understates the
middle tier, though your underlying point holds: even that tier is slower than
the fast path and should be visible.

no real objection to rejecting top-level hard refs up front […]
“top-level hard ref” confuses me

You are right to be confused, and I will retract the phrase; it is a no-op.
store($key, $value) takes $value by value, so the engine dereferences any
top-level reference (ZVAL_DEREF) before storage ever sees it. A top-level
hard ref cannot reach the storage layer as a reference. The case that matters
is a nested reference, a & inside an array element or object property, and
that cannot be rejected cheaply up front: detecting it requires walking the
whole graph, which is the walk the shared-graph builder already does. So the
honest answer for nested refs is the visibility above (the value reports the
serialize path), not an up-front rejection.

Scalars and arrays-of-scalars only

This is where the discussion helped most. I argued before that scalars-only
gave up a real win; you pushed back with measurements; so I built your setup
and measured it properly, including the large nested workloads that are the
actual case for a cache. You were right that native was losing. That sent me
into the implementation, and I found the cause and fixed it. The path is
worth setting out.

Two of your framings I agree with up front:

  1. For array-of-scalars config/metadata, an immutable interned array is
    essentially free, and the cache should not claim to beat it.
  2. The “Nx faster than APCu” headline is size-dependent; APCu is only a few
    microseconds for small payloads.

(a) The config array

an immutable array is essentially free (0.045 us) […] the static
cache’s own array fetch, which pays an O(n) walk per read and so doesn’t
even deliver the immutable-array win that opcache literals already give

You are structurally right, and I have fixed it. Two facts first. I could not
reproduce 331 us: a pure-scalar 4k-entry array fetches in about 7 us, scaling
at roughly 1.7 ns/entry, and the decode itself was already zero-copy (a
scalar array is stored once as IS_ARRAY_IMMUTABLE and returned as
ZVAL_ARR() straight into SHM). The O(n) you felt was one layer up: every
warm fetch re-walked the array in value_needs_request_local_clone() to
decide whether it needed a deep clone, when that answer is fixed at store
time. I removed that walk for shared-graph values (the same change as in
(c)); the 4k fetch is now about 0.64 us and flat in the entry count.

It is still not the 0.014 us of a resident literal read, and I am not
claiming it should be. For read-only scalar config the preload/literal path
wins, and that is fine. It is a separate matter from objects.

(b) Objects: I measured your A/B/C, found native losing, and chased why

I built this branch with APCu master and your deepclone, all NTS, JIT off,
timing warm fetches where C rebuilds the same isolated object graph B returns
(resident dehydrated array plus deepclone_from_array). As you said, native
lost, and worse as the graph grew. us/op:

array of nested ORM entities objects A apcu B native C hydrate
1000 1800 799 501
2000 4171 1903 1043
object tree 8191 1582 1736 498
9841 1928 1836 523

Two things you were right about that I had wrong: deepclone_to_array /
deepclone_from_array are generic (no per-class hydrator to charge for), and
C hands back the same isolated objects B does. So this was a real loss, not a
measurement artifact.

The cause was structural, but not where I first guessed. The warm fetch kept
a request-local prototype of the materialized graph and deep-cloned it on
every repeat fetch, and for an object graph that clone is slower than decoding
the compact SHM layout again. A shared graph never holds shared identity or
cycles, so each decode is already an independent copy; the prototype was pure
overhead. On top of that the decoder re-resolved the class
(zend_lookup_class) for every object, and the builder stored a separate copy
of each repeated class and property name.

(c) The fix

Three changes, all behind the existing API, with no visible behaviour or
format change:

  • Skip the request-local prototype for shared-graph values and decode from
    SHM on each fetch. (This also removes the O(n) array walk in (a).)
  • Deduplicate equal strings within a payload at build time, so a class or
    property name repeated across thousands of objects is stored once.
  • Memoize the resolved class per (buffer, offset) during a decode, so a
    homogeneous graph resolves its class once, not once per node.

Same A/B/C after the change, NTS, JIT off, us/op:

array of nested ORM entities objects A apcu B native C hydrate
1000 1781 357 492
2000 3868 721 1036
object tree 8191 1565 462 485
9841 1830 499 513

Native now beats deepclone on every nested workload I tried: about 1.4x on
the 2000-entity array, and the deep trees that lost 3.5x now win. The
400-object case went from 72 to 23 us. The full ext/opcache suite passes,
plus new regression tests, on NTS and ZTS.

To make this reproducible on your terms, I added a deepclone backend to my own
HTTP benchmark harness (dehydrate with deepclone_to_array(), keep the array
in the volatile cache, rehydrate with deepclone_from_array() on each fetch)
and re-ran vote_read_long under the published conditions (php-fpm + nginx
NTS and FrankenPHP ZTS, 20 iterations / 3 warmup / 3000 ops, JIT off). The
APCu baselines match the published table within about 2%, so the runtimes are
comparable. native vs deepclone, mean us/op (NTS):

workload APCu native deepclone
route_table_read 161.2 0.90 0.91 (array: tie)
large_array 90.9 0.88 0.88 (array: tie)
metadata_object_read 185.3 1.12 1.32 (native)
metadata_object_mutate 162.4 1.03 1.19 (native)
safe_direct_object 2.5 1.22 3.03 (native; deepclone slower than APCu)
carbon_datetime_object 185.4 46.0 166.3 (native, ~3.6x)
spl_collection_object 21.0 5.48 1.89 (deepclone)

So under the RFC’s own methodology native is faster than the deepclone path on
every object workload except SPL collections, and ties on arrays. The SPL case
is the one real win for deepclone, and it is specific: those classes go through
the safe-direct serialized path, whose per-fetch copy handler is heavier than
rebuilding from a flat array. I have noted it in the RFC as a concrete
follow-up (a tighter SPL copy handler); it does not change the overall picture.
The updated tables are in the RFC.

Honest edges remain: for a tiny object deepclone’s tight path is a hair faster
(sub-microsecond), and for read-only scalar config a resident literal still
wins outright, as in (a). But for the workload this feature is actually for,
large nested object graphs from a database, in-engine storage is now the
faster option.

(d) Not just performance

This does not rest on performance alone. Object support is also useful for
being built in and generic (no third-party extension, nothing to pre-generate)
and for being one primitive: the store side and the runtime cross-worker
sharing live in the same place, instead of “cache the array” plus “hydrate in
userland” wired together by every library. And the safe-direct registry is not
a userland protocol: a plain user object with no magic and no cycles or refs
takes the fast path automatically via can_restore_direct(), and the C-only
registry only covers a few internal classes whose state the generic path
cannot read. Keeping objects imposes nothing on the ecosystem.

Dropping pinned (and the attributes)

PinnedStatic on the Carbon shape is ~1.5 us […] there’s no preload
trick that reaches that number, because preload can’t bake a live object
graph into an opcode literal

Pinned is the one place a live-object representation still wins clearly, for a
reason the volatile numbers above do not capture. Pinned (and
#[PinnedStatic]) materialize the graph once per worker; after that it is a
plain static read on every subsequent request in that worker, near zero per
request. The hydration approach pays its hydrate cost on every request instead.
preload cannot reach this either: it can only intern scalar and array
literals, not bake a live object graph into an opcode literal.

The caveat is that this holds for read-only / immutable shared state, where
keeping one live instance across requests is correct; a mutable shared instance
would leak between requests. But that is a real and common case: a compiled DI
container, a routing table, config value objects. Your request-registry counter
rebuilds per request from the cache, so it does not reach the per-worker
amortization, and for the read-only data where it would help, pinned already
does it with less per-request cost.

The attributes are the ergonomic surface over that same mechanism, so I would
keep them in this RFC rather than split them out. They add no new storage
model; they remove the explicit store/fetch boilerplate for the static-state
case.

Where this leaves us

What is already done or committed: the SAPI opt-in model (the
allow_unsafe_runtime flag and the SAPI allowlist are gone, replaced by the
internal opt-in/partition API); the error model; storage-path visibility via
getCacheStoreType(); dropping the “top-level ref” idea; the config-array fix
(skipping the request-local prototype for shared graphs, which removes the
per-fetch array walk so a warm scalar-array fetch is zero-copy); and the
large-nested object path from (d), with numbers on this same A/B/C. I am
declining remember(), for the lock-safety reason above.

On the central question I went where the measurements led. You were right that
native lost as shipped; I found why (a request-local prototype clone slower
than re-decoding, plus per-object class lookups and duplicated strings), fixed
all three, and native now beats your deepclone path on the nested object
workloads, with the full opcache suite and new regression tests passing on NTS
and ZTS. For tiny objects deepclone is still a hair ahead, and for read-only
scalar config a resident literal still wins; I concede both.

So I do think in-engine object storage earns its place now, on performance and
on being a built-in, generic, single primitive (and on pinned’s per-worker
amortization for read-only state). But if the body still prefers a focused
better-APCu plus a core hydration primitive, that is an outcome I can support;
the capability matters to me more than where it sits, and the work above
transfers either way.

The revised branch is pushed and the harness is published, so you can check
the numbers directly; I will also post the full before/after A/B/C here. If you
have a methodology you would prefer, I will run that too.

Thanks again. This got much sharper because you measured it, and it sent me to
a fix I would not have found otherwise.

Jakub: the FPM pool boundary is preserved

The FPM shared hosting part is a problem […] we consider data leaks
between pools as security issues […] Maybe the solution would be to
allow it only if there is one pool enabled.

This is the concern I most wanted to get right, and I think the implementation
answers it without the single-pool restriction. Static Cache is not one cache
shared across pools. FPM creates a separate partition per worker pool in the
master, before any worker forks; each partition owns its own volatile and
pinned shared-memory backend, and each worker activates only its own pool’s
partition during child initialization, before user code runs. Every cache API,
status call, clear, and the Static Cache part of opcache_reset() operates on
the active pool’s partition. There is no API path from one pool to another
pool’s data, so the pool boundary stays a security boundary and no policy
change is needed. If a pool’s partition fails to start it gets no Static Cache;
it never falls back to a shared one.

One honest caveat, for the record: the per-pool segments are anonymous shared
mappings created in the master before fork, so a worker inherits every pool’s
segment in its address space even though it can only ever address its own
pool’s partition. That is the same exposure model as the main OPcache SHM,
which is already shared across pools today; the Static Cache is in fact more
isolated, because it is logically partitioned per pool where the script cache
is not. The data-leak-through-the-feature case you raised, one pool reading
another’s cached values through the API, does not exist in this design. If on
top of that we want address-space isolation, so a worker cannot even see
another pool’s bytes, that is a worthwhile hardening (per-pool named segments
mapped only in that pool’s children, or unmapping the others post-fork), and I
am happy to do it as a follow-up if you consider it in scope.

Your single-pool suggestion would also work, but per-pool partitions keep the
feature usable for the multi-pool shared-hosting setups where a single-cache
design would otherwise be unacceptable.

Timo: thanks for the immutable_cache pointer

See also Tyson’s php-immutable_cache […] related APCu discussions

Thank you. Tyson told me about immutable_cache himself a while ago, and it
shaped my thinking here. I built an internal extension along the same lines,
colopl_cache, an APCu-style drop-in for immutable values. What that work
showed me is that the parts that matter most for this use case (OPcache
compatibility, behaviour under a JIT-heavy workload, and the Zend VM
intervention needed for static-state caching) are very hard to get right as an
ordinary extension. That is why I brought this to OPcache as an RFC instead of
shipping another extension: it needs cooperation from the engine, the VM, and a
few internal classes that an extension cannot coordinate cleanly. So the prior
art is genuinely appreciated; it is part of how I arrived here.

Best regards,
Go Kudo

Hi, Pătrănescu.

Thanks for your advice.

As you pointed out, I have been using an LLM to compose my replies up until now.
This was because my English skills are insufficient, and I was afraid I wouldn’t be able to convey what I wanted to say.

But, your opinion is true, Text is very long and redundant. That makes it unreadable.

Currently text is written and does not use LLM. but my English skills are too bad.

However, I understand most people’s dislike LLM text, and I understand the dislike of LLM use for development.
RFC Implementation is all human reviewed & tested, but porting implementation of our colopl_cache extension with LLM.

If these LLM use cases are not acceptable to a PHP organization or community, I’m zero-scratch retiring from implementation and discussion.

I want to hear your opinion.

Best regards,
Go Kudo

On Tue, Jun 2, 2026, at 8:11 AM, Go Kudo wrote:

2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:

Hi internals,

I'd like to start the discussion for a new RFC, OPcache Static Cache.

RFC: PHP: rfc:opcache_static_cache
Implementation: [RFC] OPcache Static Cache Implementation by zeriyoshi · Pull Request #22052 · php/php-src · GitHub

The proposal adds an OPcache-managed shared-memory cache for explicit userland values and for selected PHP static state. It introduces explicit functions under the OPcache namespace (volatile_* and persistent_*) and two attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that let selected static properties and method static variables survive across requests. The feature is disabled by default and only activates once memory is allocated through the new INI directives.

The RFC covers the motivation, the deliberate split between the two backends, the trust model (one PHP runtime = one trust domain; this is not a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage summarized in the Validation section.

One thing to flag on the implementation status: the Windows build is currently broken. I don't have a Windows development environment available yet — one is being arranged through work, and I'll get the Windows side fixed once that's in place.

Feedback welcome.

Best Regards,
Go Kudo

Hi Nicolas, Jakub, Timo, Larry

I update RFC and Implementation:
RFC: PHP: rfc:opcache_static_cache
PR: [RFC] OPcache Static Cache Implementation by zeriyoshi · Pull Request #22052 · php/php-src · GitHub

I'm only responding to bits here and there, because the LLM text here is just too much for me to bother reading. (Frankly, your non-LLM follow up message was perfectly readable to me. I don't think you need it.)

It also seems like you're rewriting the RFC every time someone posts a comment. There are differences of opinion on the list, so it will be less work for you and everyone else to slow down and let more people comment before you start making radical changes.

## The resulting public API

For reference, here is the shape the explicit API settled into, summarised
from the stub:

namespace OPcache;

// Explicit cache: two final classes, static methods only, no instances.
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;
}

// PinnedCache is the same set, except set()/setMultiple() take no $ttl,
// plus two atomic counters:
final class PinnedCache
{
    // get/getMultiple/set/setMultiple/has/delete/deleteMultiple/clear/
    // lock/unlock/getCacheStoreType/info  -- as above
    public static function increment(string $key, int $step = 1): int|false;
    public static function decrement(string $key, int $step = 1): int|false;

No int|false. That’s an anti-pattern. If you must do “int or error”, at the very least use null here.

}

// getCacheStoreType() reports how a value is stored, without decoding it:
enum CacheStoreType
{
case NotFound; // no entry for the key/property
case Scalar; // stored inline
case SharedGraph; // zero-copy graph laid out in SHM (the fast path)
case OPcacheSerialized; // OPcache binary serializer (SHM-safe, no userland)
case PHPSerialized; // php_var_serialize() last resort
}

// Declarative static state, over the same storage:
#[Attribute] final class VolatileStatic {
public function __construct(int $ttl = 0, CacheStrategy $strategy =
CacheStrategy::Immediate);
}
#[Attribute] final class PinnedStatic {}
enum CacheStrategy: int { case Immediate = 0; case Tracking = 1; }

// Status object and the single exception type:
final readonly class StaticCacheInfo { /* enabled, available,
configured_memory, entry_count, … */ }
class StaticCacheException extends \Exception {}


Two final classes with static methods, no instances and no shared
interface\. Misses and contention return the default or \`false\`; genuine
backend failures return \`false\` \(or \`int|false\` for the atomic counters\);
\`Closure\` and resource values are rejected with a \`TypeError\`; and
\`StaticCacheException\` is reserved for strict \`\#\[OPcache\\PinnedStatic\]\`
publication\.

I want to be clear on this: I will absolutely vote against this proposal if it ships with static methods as the API, no matter what else it contains. That is a horrible anti-pattern and it should not be brought anywhere close to PHP's stdlib. No. Absolutely not.

Nicolas' original proposal was for regular objects, with a factory method. I also prefer a regular object, but I'd go a step further:

$volatile = new VolatileCache('some_key');
$volatile->set('key', $val);

Pass a "scoping key" (or namespace, or prefix, or whatever you want to call it) to the constructor of the cache objects. In most cases, frameworks (like Symfony or Laravel) already have an app-key value that is unique to the app instance, and that can be used probably directly. That provides a clear separation between different cache pools; even if you have a multi-tenant setup such as apache2, using different random strings for the scoping key will keep the values separate.

That gives us a clear separation, and justification for shipping on-by-default.

(This is what I meant earlier when talking about "pools." That's essentially what this is.)

Also: I really don't like the name "pinned." The opposite of "Volatile" is usually "stable". That's less misleading than "persistent" (the original name), but also less confusing than "pinned", which means nothing here.

## References and the silent fallback

I honestly didn't follow this section. Probably because of the LLM.

## Scalars and arrays-of-scalars only

This is where the discussion helped most. I argued before that scalars-only
gave up a real win; you pushed back with measurements; so I built your setup
and measured it properly, including the large nested workloads that are the
actual case for a cache. You were right that native was losing. That sent me
into the implementation, and I found the cause and fixed it. The path is
worth setting out.

Two of your framings I agree with up front:

1. For array-of-scalars config/metadata, an immutable interned array is
   essentially free, and the cache should not claim to beat it.
2. The "Nx faster than APCu" headline is size-dependent; APCu is only a few
   microseconds for small payloads.

I can see Nicolas' argument for scalar/arrays-only, but I also agree that does greatly limit its usefulness. You would need to spend a great deal of effort building an object facade for that data in many cases. That's going to eat up a large chunk of the benefit (in both dev time and run time) of this feature.

Put another way, if I can just build up a data structure on a property, stick an attribute on it, and then always use it like:

$data = self::$data ??= compute_data();

And move on with life, that's huge for DX, even if it may be slightly slower than taking the time to compile an array form of it, save it to disk (and worry about file permissions and writeability), reload it, and then rehydrate to objects, potentially. Frankly, I'd take that tradeoff more often than not.

### (d) Not just performance

This does not rest on performance alone. Object support is also useful for
being built in and generic (no third-party extension, nothing to pre-generate)
and for being one primitive: the store side and the runtime cross-worker
sharing live in the same place, instead of "cache the array" plus "hydrate in
userland" wired together by every library. And the safe-direct registry is not
a userland protocol: a plain user object with no magic and no cycles or refs
takes the fast path automatically via `can_restore_direct()`, and the C-only
registry only covers a few internal classes whose state the generic path
cannot read. Keeping objects imposes nothing on the ecosystem.

Right, that. The simplicity of the userland code is the big win for me, even if it's single-digit-percent slower than manually materializing in some cases.

## Dropping pinned (and the attributes)

PinnedStatic on the Carbon shape is ~1.5 us [...] there's no preload
trick that reaches that number, because preload can't bake a live object
graph into an opcode literal

Pinned is the one place a live-object representation still wins clearly, for a
reason the volatile numbers above do not capture. Pinned (and
`#[PinnedStatic]`) materialize the graph once per worker; after that it is a
plain static read on every subsequent request in that worker, near zero per
request. The hydration approach pays its hydrate cost on every request instead.
preload cannot reach this either: it can only intern scalar and array
literals, not bake a live object graph into an opcode literal.

The caveat is that this holds for read-only / immutable shared state, where
keeping one live instance across requests is correct; a mutable shared instance
would leak between requests. But that is a real and common case: a compiled DI
container, a routing table, config value objects. Your request-registry counter
rebuilds per request from the cache, so it does not reach the per-worker
amortization, and for the read-only data where it would help, pinned already
does it with less per-request cost.

The attributes are the ergonomic surface over that same mechanism, so I would
keep them in this RFC rather than split them out. They add no new storage
model; they remove the explicit store/fetch boilerplate for the static-state
case.

I would prefer to keep these in rather than remove them, but I wouldn't vote against the RFC if the consensus is eventually to remove them until later.

--Larry Garfield

On Tue, Jun 2, 2026 at 6:50 PM Go Kudo <zeriyoshi@gmail.com> wrote:

2026年6月2日(火) 23:53 Alexandru Pătrănescu <drealecs@gmail.com>:

Hi Go Kudo,

I am writing to you privately regarding your recent emails on the internals thread.

I appreciate the use of AI tools to ensure clear and professional communication; I often use them myself when working in other languages. However, I’ve found that the replies have become quite long, making it difficult to stay focused and read them entirely.

If you are using an AI to generate these responses, would you consider asking it to be more concise or to provide shorter summaries?
Or maybe better, try using it primarily for translation and refinement of your own notes rather than letting it generate the full bulk of the content.

I apologize if I am mistaken and you aren’t using AI for these replies, but I wanted to share this feedback in case it helps.

Best regards,
Alex

Hi, Pătrănescu.

Thanks for your advice.

As you pointed out, I have been using an LLM to compose my replies up until now.
This was because my English skills are insufficient, and I was afraid I wouldn’t be able to convey what I wanted to say.

But, your opinion is true, Text is very long and redundant. That makes it unreadable.

Currently text is written and does not use LLM. but my English skills are too bad.

However, I understand most people’s dislike LLM text, and I understand the dislike of LLM use for development.
RFC Implementation is all human reviewed & tested, but porting implementation of our colopl_cache extension with LLM.

If these LLM use cases are not acceptable to a PHP organization or community, I’m zero-scratch retiring from implementation and discussion.

I want to hear your opinion.

Just to be clear, my feedback is only about your messages on internals mailing list discussion.
And related to that, from my point of view, I have no issue with you using LLMs for writing. I would even say that it clarifies your messages compared to a few months ago, making sure it goes in a well-articulated English language format.
But try using LLMs more for translating and correcting your text and ideas and less for expanding and generating large blocks of text. Shorter, more concise text is better.
We need to be considerate of the people reading the messages and make it easy for them as well.

Keep up the good work,
Alex

Le mar. 2 juin 2026 à 15:12, Go Kudo <zeriyoshi@gmail.com> a écrit :

2026年5月17日(日) 0:19 Go Kudo <zeriyoshi@gmail.com>:

Hi internals,

I’d like to start the discussion for a new RFC, OPcache Static Cache.

RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052

The proposal adds an OPcache-managed shared-memory cache for explicit userland values and for selected PHP static state. It introduces explicit functions under the OPcache namespace (volatile_* and persistent_*) and two attributes, #[OPcache\VolatileStatic] and #[OPcache\PersistentStatic], that let selected static properties and method static variables survive across requests. The feature is disabled by default and only activates once memory is allocated through the new INI directives.

The RFC covers the motivation, the deliberate split between the two backends, the trust model (one PHP runtime = one trust domain; this is not a tenant isolation boundary), and benchmarks against APCu on NTS php-fpm and ZTS FrankenPHP. The PR is the full implementation, with PHPT coverage summarized in the Validation section.

One thing to flag on the implementation status: the Windows build is currently broken. I don’t have a Windows development environment available yet — one is being arranged through work, and I’ll get the Windows side fixed once that’s in place.

Feedback welcome.

Best Regards,
Go Kudo

Hi Nicolas, Jakub, Timo, Larry

I update RFC and Implementation:
RFC: https://wiki.php.net/rfc/opcache_static_cache
PR: https://github.com/php/php-src/pull/22052

I’m folding replies to all three of you into one message, since the
threads overlap. Most of it answers Nicolas’s measurements; further down
there is a section for Jakub’s FPM pool-isolation concern and a short note
for Timo’s pointer to prior art.

Nicolas, thank you for building my branch and running your own A/B/C
measurements. That moved the discussion onto concrete ground, and I
appreciate it.

Since your review I have pushed a revised branch and bumped the RFC to
2.0.0. The API changes discussed below are in it (the SAPI opt-in model,
and getCacheStoreType() for storage-path visibility), and the object
workloads you flagged are now substantially faster: native now beats the
deepclone path on every nested case I tried. Details and numbers follow.

I agree with most of your points. I’ll go through them in order, concede
the ones where you are right, and try to narrow what is left. I think it
comes down to one question: whether a userland array-hydration layer is an
acceptable replacement for engine-level object storage. Most of the rest I
can give you.

The resulting public API

For reference, here is the shape the explicit API settled into, summarised
from the stub:

namespace OPcache;

// Explicit cache: two final classes, static methods only, no instances.
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;
}

// PinnedCache is the same set, except set()/setMultiple() take no $ttl,
// plus two atomic counters:
final class PinnedCache
{
// get/getMultiple/set/setMultiple/has/delete/deleteMultiple/clear/
// lock/unlock/getCacheStoreType/info -- as above
public static function increment(string $key, int $step = 1): int|false;
public static function decrement(string $key, int $step = 1): int|false;
}

// getCacheStoreType() reports how a value is stored, without decoding it:
enum CacheStoreType
{
case NotFound; // no entry for the key/property
case Scalar; // stored inline
case SharedGraph; // zero-copy graph laid out in SHM (the fast path)
case OPcacheSerialized; // OPcache binary serializer (SHM-safe, no userland)
case PHPSerialized; // php_var_serialize() last resort
}

// Declarative static state, over the same storage:
#[Attribute] final class VolatileStatic {
public function __construct(int $ttl = 0, CacheStrategy $strategy = CacheStrategy::Immediate);
}
#[Attribute] final class PinnedStatic {}
enum CacheStrategy: int { case Immediate = 0; case Tracking = 1; }

// Status object and the single exception type:
final readonly class StaticCacheInfo { /* enabled, available, configured_memory, entry_count, ... */ }
class StaticCacheException extends \Exception {}

Two final classes with static methods, no instances and no shared
interface. Misses and contention return the default or false; genuine
backend failures return false (or int|false for the atomic counters);
Closure and resource values are rejected with a TypeError; and
StaticCacheException is reserved for strict #[OPcache\PinnedStatic]
publication.

SAPI availability: the unsafe flag is gone, opt-in instead

these are safe SAPIs, they just don’t have a scoping concept built in
[…] enable it by default with a single default scope for those SAPIs,
plus a clear internal API so a SAPI can define its own scoped segments

I implemented it the way you suggested. There is no longer an
opcache.static_cache.allow_unsafe_runtime directive and no SAPI-name
allowlist in the engine. Availability is opt-in: a SAPI, or an embedder,
calls a small internal C API, zend_opcache_static_cache_opt_in(), before
request handling to enable Static Cache for its runtime. That call is the
runtime declaring that a trust/storage boundary holds for the lifetime of
the shared-memory owner.

The bundled fpm, cli, cli-server and phpdbg SAPIs call it at
startup, so they are available by default. The difference from before is the
mechanism: instead of the engine guessing from the SAPI name and offering an
“unsafe” override, each runtime states that it owns a boundary. A runtime
with a real per-tenant boundary scopes it with the partition API
(zend_opcache_static_cache_partition_create / _activate, which fpm
already uses per pool). A runtime without one, such as a shared multi-tenant
web SAPI with no pre-request identity, never opts in and stays unavailable,
with nothing left to misconfigure.

The embed SAPI does not auto-opt-in, on purpose. The embedding application
owns the runtime and its trust boundary, so it opts in from its own startup
code. That keeps the rule consistent for every embedder, including one that
registers its own SAPI module instead of reusing the bundled embed one.
FrankenPHP does exactly that, so it opts in with the same one-line call (or a
scoped partition when it isolates per worker); there is no embed
special-case that covers php_embed users but silently misses FrankenPHP.

That is your internal-API point, and it removes the naming question by
deleting the flag entirely. The full ext/opcache suite passes with the
directive gone.

API shape: remember()

I could also add VolatileCache::remember($key, $compute, $ttl = 0)
wrapping the safe lock → build-outside-the-lock → store sequence

I would rather not add this one. remember() takes a callable, and to
actually prevent a stampede it has to hold the entry lock across the call to
$compute(). That means running arbitrary userland PHP while holding a
cross-process SHM lock. The callable can run unbounded, throw, fork, or
re-enter the cache, and a re-entrant lock() on the same key (or a key in
the same lock stripe) while the lock is held is a deadlock. The lease bounds
the duration, but not the re-entrancy and not the exception path.

Not holding the lock while computing gives no stampede protection at all; it
is then just sugar over get()-then-set() that looks atomic, which is
worse than not having it.

Since I already expose lock()/unlock() with a lease, userland can do the
safe thing itself, with the compute step outside any engine lock:

if (!VolatileCache::lock($key, $lease)) {
return VolatileCache::get($key, $default);
}
try {
$value = $compute(); // runs outside the engine lock
VolatileCache::set($key, $value, $ttl);
return $value;
} finally {
VolatileCache::unlock($key);
}

That keeps the closure’s execution, its scope, and any exception it throws in
userland, never inside the engine’s critical section. I would rather document
this recipe than move userland execution into the primitive. If you see a
safe construction I have missed, I will reconsider.

References and the silent fallback

I’d rather make it visible (surface the chosen path in info(), or in a
debug build) than ban objects

Agreed, and that is implemented: visibility, not a ban. There is a new
introspection method on both cache classes:

VolatileCache::getCacheStoreType(string $key_or_property, ?string $class_name = null): OPcache\CacheStoreType
PinnedCache::getCacheStoreType(string $key_or_property, ?string $class_name = null): OPcache\CacheStoreType

It returns an OPcache\CacheStoreType enum (NotFound, Scalar,
SharedGraph, OPcacheSerialized, PHPSerialized), so you can see per key
which path a value took, without decoding it, in any build rather than only a
debug one. Passing $class_name inspects the attribute-backed
static-property storage for that class instead of an explicit key. A value
that fell back to serialization is now one call away from being observable.

The enum also pins down a correction. The first fallback off the shared graph
is not php_var_serialize but the OPcache binary serializer, which is
SHM-safe and runs no userland code. That is why getCacheStoreType reports
OPcacheSerialized and PHPSerialized as separate cases; php_var_serialize
is the last resort, not the first. So “bail == APCu parity” understates the
middle tier, though your underlying point holds: even that tier is slower than
the fast path and should be visible.

no real objection to rejecting top-level hard refs up front […]
“top-level hard ref” confuses me

You are right to be confused, and I will retract the phrase; it is a no-op.
store($key, $value) takes $value by value, so the engine dereferences any
top-level reference (ZVAL_DEREF) before storage ever sees it. A top-level
hard ref cannot reach the storage layer as a reference. The case that matters
is a nested reference, a & inside an array element or object property, and
that cannot be rejected cheaply up front: detecting it requires walking the
whole graph, which is the walk the shared-graph builder already does. So the
honest answer for nested refs is the visibility above (the value reports the
serialize path), not an up-front rejection.

Scalars and arrays-of-scalars only

This is where the discussion helped most. I argued before that scalars-only
gave up a real win; you pushed back with measurements; so I built your setup
and measured it properly, including the large nested workloads that are the
actual case for a cache. You were right that native was losing. That sent me
into the implementation, and I found the cause and fixed it. The path is
worth setting out.

Two of your framings I agree with up front:

  1. For array-of-scalars config/metadata, an immutable interned array is
    essentially free, and the cache should not claim to beat it.
  2. The “Nx faster than APCu” headline is size-dependent; APCu is only a few
    microseconds for small payloads.

(a) The config array

an immutable array is essentially free (0.045 us) […] the static
cache’s own array fetch, which pays an O(n) walk per read and so doesn’t
even deliver the immutable-array win that opcache literals already give

You are structurally right, and I have fixed it. Two facts first. I could not
reproduce 331 us: a pure-scalar 4k-entry array fetches in about 7 us, scaling
at roughly 1.7 ns/entry, and the decode itself was already zero-copy (a
scalar array is stored once as IS_ARRAY_IMMUTABLE and returned as
ZVAL_ARR() straight into SHM). The O(n) you felt was one layer up: every
warm fetch re-walked the array in value_needs_request_local_clone() to
decide whether it needed a deep clone, when that answer is fixed at store
time. I removed that walk for shared-graph values (the same change as in
(c)); the 4k fetch is now about 0.64 us and flat in the entry count.

It is still not the 0.014 us of a resident literal read, and I am not
claiming it should be. For read-only scalar config the preload/literal path
wins, and that is fine. It is a separate matter from objects.

(b) Objects: I measured your A/B/C, found native losing, and chased why

I built this branch with APCu master and your deepclone, all NTS, JIT off,
timing warm fetches where C rebuilds the same isolated object graph B returns
(resident dehydrated array plus deepclone_from_array). As you said, native
lost, and worse as the graph grew. us/op:

array of nested ORM entities objects A apcu B native C hydrate
1000 1800 799 501
2000 4171 1903 1043
object tree 8191 1582 1736 498
9841 1928 1836 523

Two things you were right about that I had wrong: deepclone_to_array /
deepclone_from_array are generic (no per-class hydrator to charge for), and
C hands back the same isolated objects B does. So this was a real loss, not a
measurement artifact.

The cause was structural, but not where I first guessed. The warm fetch kept
a request-local prototype of the materialized graph and deep-cloned it on
every repeat fetch, and for an object graph that clone is slower than decoding
the compact SHM layout again. A shared graph never holds shared identity or
cycles, so each decode is already an independent copy; the prototype was pure
overhead. On top of that the decoder re-resolved the class
(zend_lookup_class) for every object, and the builder stored a separate copy
of each repeated class and property name.

(c) The fix

Three changes, all behind the existing API, with no visible behaviour or
format change:

  • Skip the request-local prototype for shared-graph values and decode from
    SHM on each fetch. (This also removes the O(n) array walk in (a).)
  • Deduplicate equal strings within a payload at build time, so a class or
    property name repeated across thousands of objects is stored once.
  • Memoize the resolved class per (buffer, offset) during a decode, so a
    homogeneous graph resolves its class once, not once per node.

Same A/B/C after the change, NTS, JIT off, us/op:

array of nested ORM entities objects A apcu B native C hydrate
1000 1781 357 492
2000 3868 721 1036
object tree 8191 1565 462 485
9841 1830 499 513

Native now beats deepclone on every nested workload I tried: about 1.4x on
the 2000-entity array, and the deep trees that lost 3.5x now win. The
400-object case went from 72 to 23 us. The full ext/opcache suite passes,
plus new regression tests, on NTS and ZTS.

To make this reproducible on your terms, I added a deepclone backend to my own
HTTP benchmark harness (dehydrate with deepclone_to_array(), keep the array
in the volatile cache, rehydrate with deepclone_from_array() on each fetch)
and re-ran vote_read_long under the published conditions (php-fpm + nginx
NTS and FrankenPHP ZTS, 20 iterations / 3 warmup / 3000 ops, JIT off). The
APCu baselines match the published table within about 2%, so the runtimes are
comparable. native vs deepclone, mean us/op (NTS):

workload APCu native deepclone
route_table_read 161.2 0.90 0.91 (array: tie)
large_array 90.9 0.88 0.88 (array: tie)
metadata_object_read 185.3 1.12 1.32 (native)
metadata_object_mutate 162.4 1.03 1.19 (native)
safe_direct_object 2.5 1.22 3.03 (native; deepclone slower than APCu)
carbon_datetime_object 185.4 46.0 166.3 (native, ~3.6x)
spl_collection_object 21.0 5.48 1.89 (deepclone)

So under the RFC’s own methodology native is faster than the deepclone path on
every object workload except SPL collections, and ties on arrays. The SPL case
is the one real win for deepclone, and it is specific: those classes go through
the safe-direct serialized path, whose per-fetch copy handler is heavier than
rebuilding from a flat array. I have noted it in the RFC as a concrete
follow-up (a tighter SPL copy handler); it does not change the overall picture.
The updated tables are in the RFC.

Honest edges remain: for a tiny object deepclone’s tight path is a hair faster
(sub-microsecond), and for read-only scalar config a resident literal still
wins outright, as in (a). But for the workload this feature is actually for,
large nested object graphs from a database, in-engine storage is now the
faster option.

(d) Not just performance

This does not rest on performance alone. Object support is also useful for
being built in and generic (no third-party extension, nothing to pre-generate)
and for being one primitive: the store side and the runtime cross-worker
sharing live in the same place, instead of “cache the array” plus “hydrate in
userland” wired together by every library. And the safe-direct registry is not
a userland protocol: a plain user object with no magic and no cycles or refs
takes the fast path automatically via can_restore_direct(), and the C-only
registry only covers a few internal classes whose state the generic path
cannot read. Keeping objects imposes nothing on the ecosystem.

Dropping pinned (and the attributes)

PinnedStatic on the Carbon shape is ~1.5 us […] there’s no preload
trick that reaches that number, because preload can’t bake a live object
graph into an opcode literal

Pinned is the one place a live-object representation still wins clearly, for a
reason the volatile numbers above do not capture. Pinned (and
#[PinnedStatic]) materialize the graph once per worker; after that it is a
plain static read on every subsequent request in that worker, near zero per
request. The hydration approach pays its hydrate cost on every request instead.
preload cannot reach this either: it can only intern scalar and array
literals, not bake a live object graph into an opcode literal.

The caveat is that this holds for read-only / immutable shared state, where
keeping one live instance across requests is correct; a mutable shared instance
would leak between requests. But that is a real and common case: a compiled DI
container, a routing table, config value objects. Your request-registry counter
rebuilds per request from the cache, so it does not reach the per-worker
amortization, and for the read-only data where it would help, pinned already
does it with less per-request cost.

The attributes are the ergonomic surface over that same mechanism, so I would
keep them in this RFC rather than split them out. They add no new storage
model; they remove the explicit store/fetch boilerplate for the static-state
case.

Where this leaves us

What is already done or committed: the SAPI opt-in model (the
allow_unsafe_runtime flag and the SAPI allowlist are gone, replaced by the
internal opt-in/partition API); the error model; storage-path visibility via
getCacheStoreType(); dropping the “top-level ref” idea; the config-array fix
(skipping the request-local prototype for shared graphs, which removes the
per-fetch array walk so a warm scalar-array fetch is zero-copy); and the
large-nested object path from (d), with numbers on this same A/B/C. I am
declining remember(), for the lock-safety reason above.

On the central question I went where the measurements led. You were right that
native lost as shipped; I found why (a request-local prototype clone slower
than re-decoding, plus per-object class lookups and duplicated strings), fixed
all three, and native now beats your deepclone path on the nested object
workloads, with the full opcache suite and new regression tests passing on NTS
and ZTS. For tiny objects deepclone is still a hair ahead, and for read-only
scalar config a resident literal still wins; I concede both.

So I do think in-engine object storage earns its place now, on performance and
on being a built-in, generic, single primitive (and on pinned’s per-worker
amortization for read-only state). But if the body still prefers a focused
better-APCu plus a core hydration primitive, that is an outcome I can support;
the capability matters to me more than where it sits, and the work above
transfers either way.

The revised branch is pushed and the harness is published, so you can check
the numbers directly; I will also post the full before/after A/B/C here. If you
have a methodology you would prefer, I will run that too.

Thanks again. This got much sharper because you measured it, and it sent me to
a fix I would not have found otherwise.

Jakub: the FPM pool boundary is preserved

The FPM shared hosting part is a problem […] we consider data leaks
between pools as security issues […] Maybe the solution would be to
allow it only if there is one pool enabled.

This is the concern I most wanted to get right, and I think the implementation
answers it without the single-pool restriction. Static Cache is not one cache
shared across pools. FPM creates a separate partition per worker pool in the
master, before any worker forks; each partition owns its own volatile and
pinned shared-memory backend, and each worker activates only its own pool’s
partition during child initialization, before user code runs. Every cache API,
status call, clear, and the Static Cache part of opcache_reset() operates on
the active pool’s partition. There is no API path from one pool to another
pool’s data, so the pool boundary stays a security boundary and no policy
change is needed. If a pool’s partition fails to start it gets no Static Cache;
it never falls back to a shared one.

One honest caveat, for the record: the per-pool segments are anonymous shared
mappings created in the master before fork, so a worker inherits every pool’s
segment in its address space even though it can only ever address its own
pool’s partition. That is the same exposure model as the main OPcache SHM,
which is already shared across pools today; the Static Cache is in fact more
isolated, because it is logically partitioned per pool where the script cache
is not. The data-leak-through-the-feature case you raised, one pool reading
another’s cached values through the API, does not exist in this design. If on
top of that we want address-space isolation, so a worker cannot even see
another pool’s bytes, that is a worthwhile hardening (per-pool named segments
mapped only in that pool’s children, or unmapping the others post-fork), and I
am happy to do it as a follow-up if you consider it in scope.

Your single-pool suggestion would also work, but per-pool partitions keep the
feature usable for the multi-pool shared-hosting setups where a single-cache
design would otherwise be unacceptable.

Timo: thanks for the immutable_cache pointer

See also Tyson’s php-immutable_cache […] related APCu discussions

Thank you. Tyson told me about immutable_cache himself a while ago, and it
shaped my thinking here. I built an internal extension along the same lines,
colopl_cache, an APCu-style drop-in for immutable values. What that work
showed me is that the parts that matter most for this use case (OPcache
compatibility, behaviour under a JIT-heavy workload, and the Zend VM
intervention needed for static-state caching) are very hard to get right as an
ordinary extension. That is why I brought this to OPcache as an RFC instead of
shipping another extension: it needs cooperation from the engine, the VM, and a
few internal classes that an extension cannot coordinate cleanly. So the prior
art is genuinely appreciated; it is part of how I arrived here.

Best regards,
Go Kudo

Thanks,

Larry’s right, I’m just one reviewer among others. I’m raising the points I make not as rebuttals but as food for thought for everybody interested also. And I’m interested in reading what others think about the points I make, especially the object-serialization part.

I take no criticism of deepclone at all when you improve your implementation.

I submitted a PR on your fork [1] with a significant cleanup of the implementation, basically removing the serialize fallback when copying into SHM. That drops entirely the need for getCacheStoreType() and its enum, which exposed internal concerns anyway.
The result is both faster, a win for all.

What remains to me (in no specific order):

  1. Reserved keys and the FQCN rejection. set()/get() still reject keys starting with the reserved prefixes and reject loaded class names, and delete() still takes $key_or_class, because of the attributes. That’s leaky to me.

  2. Accepting objects at all - aka implicit serialize on set(), which I argued as something leaky also to me. I appreciate that you showed that doing this in one go provides some perf benefits over my two steps approach. My abstraction-related arguments still stand and I’d be happy to see what others think about this.

  3. static methods - see Larry’s reply

  4. Attributes. You kept them as “ergonomic surface, no new storage model”, but that does not answer the cost I raised: the JIT paths, the VM hooks, and the CacheStrategy::Tracking machinery exist only for the attribute case, and the reserved-key leak in #1 is their concrete footprint on the explicit API. None of that is needed by the explicit cache. That is exactly why I would split them into a follow-up: the explicit cache can land and be reviewed on its own, while the attribute semantics (cross-request shared mutable state, mutation tracking) get the separate scrutiny they deserve. Bundled, they couple the review of a simple primitive to the riskiest part of the patch.

  5. Pinned/non-volatile I remain unconvinced. You proved that “materialize once per worker, then a near-zero static read per request” is something per-request hydrate cannot match. That is real but it is narrow. If one cares about perf that much, then moving to a worker-based runtime model (aka FrankenPHP workers) provides way more evident perf improvement and doesn’t need pinning at all since there, static properties are live for a worker-long duration.

I feel like it could be easier for everybody to agree on a tighter RFC and that’s what I’m trying to help figure out - the MVP of your proposal.

Again, I’d be happy to read more feedback from others now that I laid my current stand.

Thanks,
Nicolas

[1] https://github.com/colopl/php-src/pull/4