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'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/22052The 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. :-)
-
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:
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/22052The 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 KudoInteresting! 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. :-)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,
matchingpersistent_atomic_increment(). -
volatile_lock()andpersistent_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\StaticCacheInfoobject
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 immediatemode,VolatileStatic trackingmode, andPersistentStatichave been expanded. -
VolatileStatic trackingis 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:
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/22052The 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 KudoInteresting! 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. :-)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,
matchingpersistent_atomic_increment().volatile_lock()andpersistent_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\StaticCacheInfoobject
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 immediatemode,
VolatileStatic trackingmode, andPersistentStatichave been expanded.VolatileStatic trackingis 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
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,
matchingpersistent_atomic_increment().
+1
volatile_lock()andpersistent_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. :-)
- 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? :-) 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 immediatemode,
VolatileStatic trackingmode, andPersistentStatichave been expanded.VolatileStatic trackingis 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:
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,
matchingpersistent_atomic_increment().+1
volatile_lock()andpersistent_lock()now have matching
*_unlock()
functions.I should note that this is an excellent example of where context managers
and ausingblock would be helpful. :-)
- 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? :-) 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 immediatemode,
VolatileStatic trackingmode, andPersistentStatichave been expanded.VolatileStatic trackingis 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 PersistentStatic → PinnedStatic (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\StaticCacheInfofor both backends
(unchanged shape; only the accessor onopcache_get_status()is
renamed) - Internal key prefixes:
pinned_static:andpinned_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. :-)
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/22052The 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/22052The 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/22052The 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,
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,
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/22052The 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,
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/22052The 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 :) 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,
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/22052The 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 :) 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 toserialize()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:
- shared graph: built straight into SHM, fetched with no userland code
- the OPcache serializer: SHM-safe binary encode, bytes copied under the
lock and rebuilt after it's released, still no userland code - 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,
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/22052The 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 :) 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 toserialize()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,
NicolasHi 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 :) 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 beserialize()'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 usThe ~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:
- shared graph: built straight into SHM, fetched with no userland code
- the OPcache serializer: SHM-safe binary encode, bytes copied under
the lock and rebuilt after it's released, still no userland code- 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 ininfo(), 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/22052The 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:
- For array-of-scalars config/metadata, an immutable interned array is
essentially free, and the cache should not claim to beat it. - 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年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/22052The 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 KudoHi 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 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:
- For array-of-scalars config/metadata, an immutable interned array is
essentially free, and the cache should not claim to beat it.- 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 viacan_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 literalPinned 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
2026年6月3日(水) 1:57 Larry Garfield larry@garfieldtech.com:
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/22052The 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 KudoHi 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/22052I'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:
- For array-of-scalars config/metadata, an immutable interned array is
essentially free, and the cache should not claim to beat it.- 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 viacan_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 literalPinned 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
Hi Larry, Nicolas,
Thanks for your response. I'm trying to read my sent LLM-ish email, To be
honest, it wasn't completely readable.
Apologize to all internals ML members. I don't repeat it.
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.
I understand Larray and Nicolas' API design. This is the most fluent and
modern and clean pool-separation idea.
If there are no objections from other members, I implement these solutions.
(but, I will wait and see until other points are clarified)
But, I think keeping the SAPI based opt-in mechanism, This is a security
reason in a multi-tenant environment.
embed SAPI has default non opt-in state, but FrankenPHP SAPI can enable
this yourself. (I attached FrankenPHP patch to RFC)
No int|false. That's an anti-pattern. If you must do "int or error", at
the very least use null here.
Yes. I think null is the best option.
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.
OK. I didn't have a naming opinion. these acceptable changes.
I will explain in my own words the points that my LLM-like documentation
didn't clarify. Currently implementation is best performance in almost
all workloads.
Nicolas's ext-deepclone has better performance in non Attribute-based
cache pattern only, API cache pattern was not best performance,
These facts written latest RFC benchmark results.
(My English skill is too bad, I have absolutely no intention of criticizing
Nicolas's implementation as inferior. Please do not misunderstand)
I have one more question, is Attribute cache implementation the correct
approach?
I think this approach is the best performance effort and easy to integrate
into existing mechanisms.
(These text isn't use LLM, If you can't read mean, Please replying to me)
Best regards,
Go Kudo
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/22052The 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 KudoHi 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/22052I'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,
andgetCacheStoreType()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 orfalse; genuine
backend failures returnfalse(orint|falsefor the atomic counters);
Closureand resource values are rejected with aTypeError; and
StaticCacheExceptionis 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 segmentsI implemented it the way you suggested. There is no longer an
opcache.static_cache.allow_unsafe_runtimedirective 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-serverandphpdbgSAPIs 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, whichfpm
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
embedSAPI 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 bundledembedone.
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 noembed
special-case that coversphp_embedusers 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 sequenceI 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-entrantlock()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 overget()-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 objectsAgreed, 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\CacheStoreTypeIt returns an
OPcache\CacheStoreTypeenum (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_nameinspects 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 notphp_var_serializebut the OPcache binary serializer, which is
SHM-safe and runs no userland code. That is whygetCacheStoreTypereports
OPcacheSerializedandPHPSerializedas 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 meYou are right to be confused, and I will retract the phrase; it is a no-op.
store($key, $value)takes$valueby 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:
- For array-of-scalars config/metadata, an immutable interned array is
essentially free, and the cache should not claim to beat it.- 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 giveYou 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 asIS_ARRAY_IMMUTABLEand returned as
ZVAL_ARR()straight into SHM). The O(n) you felt was one layer up: every
warm fetch re-walked the array invalue_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 plusdeepclone_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 523Two things you were right about that I had wrong:
deepclone_to_array/
deepclone_from_arrayare 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 513Native 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 withdeepclone_to_array(), keep the
array
in the volatile cache, rehydrate withdeepclone_from_array()on each
fetch)
and re-ranvote_read_longunder 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 viacan_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 literalPinned 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_runtimeflag 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
decliningremember(), 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 ofopcache_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_cachehimself 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):
-
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. -
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. -
static methods - see Larry's reply
-
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. -
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
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.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.
FrankenPHP has been sitting in the back of my head for this whole discussion. :-) The problem is that we are looking at three different levels of potential caching.
This RFC - relatively easy to use, may or may not need to re-materialize objects, fairly fast.
Compile to disk - hard to implement well, need to re-materialize objects, even faster.
Persistent process (Franken, Swoole, etc.) - Super simple to use, fastest option available.
I like the idea of this RFC, but part of me wonders if we should just say "use Franken, really." But then the fallback if you aren't running Fraken is the hardest to implement option. So why would I do that, when I have an easier to use option?
So, potentially, would that mean the options are "use this RFC or Franken, and the compiled option just kinda fades away?" (At least for data; for generated classes we'd still need it.) I'm not sure.
Just me thinking aloud...
--Larry Garfield
2026年6月9日(火) 18:58 Larry Garfield larry@garfieldtech.com:
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.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.FrankenPHP has been sitting in the back of my head for this whole
discussion. :-) The problem is that we are looking at three different
levels of potential caching.This RFC - relatively easy to use, may or may not need to re-materialize
objects, fairly fast.
Compile to disk - hard to implement well, need to re-materialize objects,
even faster.
Persistent process (Franken, Swoole, etc.) - Super simple to use, fastest
option available.I like the idea of this RFC, but part of me wonders if we should just say
"use Franken, really." But then the fallback if you aren't running Fraken
is the hardest to implement option. So why would I do that, when I have an
easier to use option?So, potentially, would that mean the options are "use this RFC or Franken,
and the compiled option just kinda fades away?" (At least for data; for
generated classes we'd still need it.) I'm not sure.Just me thinking aloud...
--Larry Garfield
Hi Larry
So, potentially, would that mean the options are "use this RFC or
Franken, and the compiled option just kinda fades away?" (At least for
data; for generated classes we'd still need it.) I'm not sure.
Yes, that's essentially what I have in mind.
However, adapting existing applications to support FrankenPHP (and other
memory-resident runners) requires a considerable amount of effort.
What I want to achieve with this RFC is to reach that intermediate point—
to make a memory-resident cache available on an opt-in basis,
even in the traditional execution model, where needed.
Of course, I'm aware that there are "workarounds" using preload today.
However, those only work with scalar types. Applying such optimizations to
existing applications at a "truly effective level" would likely require
about
the same amount of effort as migrating to FrankenPHP.
This RFC fixes this problem at its root. It is entirely opt-in, so users can
adopt the feature only after verifying its safety and reliability.
Furthermore, I believe that implementing this via Attributes offers the best
cost-benefit ratio. An Attribute-based implementation is the only approach
that
can rival a true memory-resident one.
To achieve this, I think an explanation of this complex background and
consensus building are necessary.
I considered postponing the Attribute-based implementation, but perhaps I
should have shared this premise first.
I hope this helps move the discussion forward.
Best regards,
Go Kudo
Hi internals and All OPcache Static Cache RFC discussion members.
I've made significant changes to the API to bring it to a form that most
people will find acceptable.
Currently the implementation stub is here
https://github.com/colopl/php-src/blob/982a5f4d6bffd033f9c5a02586327c86fcef1cda/ext/opcache/opcache.stub.php
And thank you Nicolas, Hi's more optimization and simplification bring
better performance and simplism.
This means you ever have to worry about how values are stored anymore.
Serializer is completely gone.
https://github.com/colopl/php-src/pull/4
I sent this email because I wanted to hear thoughts on whether
I should decide to remove the proposed implementation of Attribute based
cache from this RFC.
Attribute-based caching is highly efficient and consistently delivers the
best performance.
But, it also requires the OPcache JIT to intervene.
This requires a review by Derick, the developer of JIT, and must be handled
with caution.
Furthermore, the argument that FrankenPHP should be used if that level of
performance is required certainly makes sense.
Originally, I drafted this RFC with the goal of “improving performance as
an extension of the existing PHP SAPI,”
so this would deviate from my initial objective. However, now that I
realize there is a demand for a “more faster integrated APCu,”
I feel that this goal might need to be deprioritized.
This RFC also sparked a discussion about how security boundaries should be
handled.
I’ve chosen to implement it in a way that SAPI isn’t enabled unless
explicitly opted into
Do you think this meets the requirements? (I’d especially like to ask Jakub
about this.)
To explain the process step by step, here is how I would like to proceed:
- Narrow the scope of this RFC, reach consensus and hold a vote as soon as
possible, and ensure it can be incorporated into 8.6. - Create a new RFC on Attribute-based Caching to initiate a new discussion
(though this may not be ready in time for 8.6.)
Please share your opinions.
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/22052The 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,
Hi internals and All OPcache Static Cache RFC discussion members.
I've made significant changes to the API to bring it to a form that most
people will find acceptable.
Currently the implementation stub is hereAnd thank you Nicolas, Hi's more optimization and simplification bring
better performance and simplism.
This means you ever have to worry about how values are stored anymore.
Serializer is completely gone.
https://github.com/colopl/php-src/pull/4I sent this email because I wanted to hear thoughts on whether
I should decide to remove the proposed implementation of Attribute based
cache from this RFC.Attribute-based caching is highly efficient and consistently delivers the
best performance.
But, it also requires the OPcache JIT to intervene.This requires a review by Derick, the developer of JIT, and must be
handled with caution.
I think you mean Dmitry.
Furthermore, the argument that FrankenPHP should be used if that level of
performance is required certainly makes sense.Originally, I drafted this RFC with the goal of “improving performance as
an extension of the existing PHP SAPI,”
so this would deviate from my initial objective. However, now that I
realize there is a demand for a “more faster integrated APCu,”
I feel that this goal might need to be deprioritized.This RFC also sparked a discussion about how security boundaries should be
handled.
I’ve chosen to implement it in a way that SAPI isn’t enabled unless
explicitly opted into
Do you think this meets the requirements? (I’d especially like to ask
Jakub about this.)
Unfortunately I don't currently have much time as I need to spend all my
time on stream (we have got quite tight deadline for it). The soonest I can
properly spend time on this would be October.
To explain the process step by step, here is how I would like to proceed:
- Narrow the scope of this RFC, reach consensus and hold a vote as soon
as possible, and ensure it can be incorporated into 8.6.- Create a new RFC on Attribute-based Caching to initiate a new
discussion (though this may not be ready in time for 8.6.)
It depends on other reviewers as well but I think 8.6 might be a bit too
optimistic for this considering the implementation size a potentially
security impact if it doesn't work correctly.
Kind regards,
Jakub
Konnichiwa
Am 2026-05-16 17:19, schrieb Go Kudo:
RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052
Thank you for your RFC. I am seeing there already was discussion and
changes to the RFC. This seems to have resulted in some inconsistencies.
The "Proposal" section still mentions "the volatile cache, used by
OPcache\volatile_* […]", but these functions have been renamed. The same
applies to the next bullet point for the pinned cache and to the "Why
two cache backends" section. Maybe there is even more places where the
old function names are referenced.
I also noticed some problems with the "Coding Standards and Naming
Policy"
(https://github.com/php/policies/blob/main/coding-standards-and-naming.rst).
Namely:
- The namespace should be called
Opcachefor proper PascalCase. - The
CacheStoreTypecases should follow PascalCase, so
OpcacheSerializedandPhpSerialized. - There must be a
OpcacheExceptionbase exception:class OpcacheException extends Exception { }. StaticCacheExceptionmust extend from theOpcacheException.- The policy does not say anything about properties, but it is
generally accepted that properties should also use camelCase instead of
underscores. So it needs to be$startupFailedinstead of
$startup_failedand similar.
I'm still working through the full RFC and the discussion. From what I
see the points above are still valid in the latest version of the RFC
and the discussion. That is why I am already sending them now.
Best regards
Tim Düsterhus
2026年6月8日(月) 20:09 Tim Düsterhus tim@bastelstu.be:
Konnichiwa
Am 2026-05-16 17:19, schrieb Go Kudo:
RFC: https://wiki.php.net/rfc/opcache_static_cache
Implementation: https://github.com/php/php-src/pull/22052Thank you for your RFC. I am seeing there already was discussion and
changes to the RFC. This seems to have resulted in some inconsistencies.The "Proposal" section still mentions "the volatile cache, used by
OPcache\volatile_* […]", but these functions have been renamed. The same
applies to the next bullet point for the pinned cache and to the "Why
two cache backends" section. Maybe there is even more places where the
old function names are referenced.I also noticed some problems with the "Coding Standards and Naming
Policy"
(https://github.com/php/policies/blob/main/coding-standards-and-naming.rst).Namely:
- The namespace should be called
Opcachefor proper PascalCase.- The
CacheStoreTypecases should follow PascalCase, so
OpcacheSerializedandPhpSerialized.- There must be a
OpcacheExceptionbase exception:class OpcacheException extends Exception { }.StaticCacheExceptionmust extend from theOpcacheException.- The policy does not say anything about properties, but it is
generally accepted that properties should also use camelCase instead of
underscores. So it needs to be$startupFailedinstead of
$startup_failedand similar.I'm still working through the full RFC and the discussion. From what I
see the points above are still valid in the latest version of the RFC
and the discussion. That is why I am already sending them now.Best regards
Tim Düsterhus
こんにちは Tim, お世話になっています
I have recovered my health thanks to the medication. :)
Currently implementation has 3.0.0, but RFC document version is 2.0.0,
Document is stale status.
I'm appealing to the Remove Attribute function currently, Attribute
implementation requires the change of JIT implementations.
But currently do not respond to ML members. I stopped updating RFC
documents and implementations.
(Sorry for my bad English, I didn't say this with the intention of
criticizing the ML members.)
I've implemented this as a pure PHP extension for use at my company. but
performance is too bad compared
to this RFC implementation.
I understand there are security concerns associated with SHM sharing, and I
believe I addressed these through
an opt-in process on the SAPI side. I would appreciate your feedback on
whether this is a sufficient solution.
If I were to implement Attribute, this RFC would provide a very fast and
efficient cache, but do you think we could get Dmitry's approval?
(Derick, I apologize for getting your name wrong,)
What is the best option I can offer right now? For now, I want to implement
this RFC in a way that everyone desires.
Best regards,
Go Kudo
Hey Go,
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/22052The 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.Feedback welcome.
Best Regards,
Go Kudo
I've been trying to digest the RFC and it's quite long-winded.
The one thing I don't grasp is the "Security and Trust Model". Sure, if
you have the worker pools, like in fpm, it absolutely makes sense to use it.
But why is it fundamentally required to have this sort of separation?
What's the point? It just means that the whole webserver is a single
boundary rather than having the ability to split more precisely. Which
in the end is a configuration / system administration setup issue,
rather than a fundamental flaw.
To me it's a non-starter to exclude apache2handler SAPI from this feature.
Regarding the API:
I think it might make sense to make the caches non-static classes, with
a constructor accepting an optional arbitrary namespace; further split
the overloaded behaviour of delete() and getCacheStoreType() - let's not
mix classes and arbitrary keys:
abstract class Cache {
public abstract function get(string $key,
null|bool|int|float|string|array|object $default = null):
null|bool|int|float|string|array|object;
public abstract function getMultiple(array $keys, ?array $default =
null): array|false;
public abstract function set(string $key,
null|bool|int|float|string|array|object $value): bool;
public abstract function setMultiple(array $values): bool;
public abstract function has(string $key): bool;
public abstract function delete(string $key): bool;
public abstract function deleteMultiple(array $keys): bool;
public abstract function clear(): bool;
public abstract function lock(string $key, int $lease = 0): bool;
public abstract function unlock(string $key): bool;
public static function clearClass(string $class_name): bool;
public static function getCacheStoreType(string $key): CacheStoreType;
public static function getPropertyCacheStoreType(string
$class_name, string $property): CacheStoreType;
public abstract static function clearAll(): bool;
public abstract static function info(): StaticCacheInfo;
}
class VolatileCache extends Cache {
public function __construct(string $namespace = "");
// Note that set() and setMultiple() are redeclared with optional
int $ttl = 0 parameters
public function set(string $key,
null|bool|int|float|string|array|object $value, int $ttl = 0): bool;
public function setMultiple(array $values, int $ttl = 0): bool;
public static function clearAll(): bool;
public static function info(): StaticCacheInfo;
}
class PinnedCache extends Cache {
public function __construct(string $namespace = "");
public function increment(string $key, int $step = 1): int|false;
public function decrement(string $key, int $step = 1): int|false;
public static function clearAll(): bool;
public static function info(): StaticCacheInfo;
}
Given that the API is nearly identical, we can simplify it for both
cache types and any library which actually requires $ttl can explicitly
require VolatileCache instead of PinnedCache - with just $ttl being
different.
An application author who does not want to carry around an instance of
the caches, can trivially write define('VOLATILE', new VolatileCache);
once and write VOLATILE->get("mykey") everywhere, getting the same
usability than Volatile::get("mykey"), essentially.
This will allow you to inject well-scoped caches (instead of relying on
the libraries to prefix their keys), declare new cache impls (e.g.
"class LocalCache extends Cache", which would just do request-local
caching rather than actually storing it) and make it easy to select
between PinnedCache and VolatileCache as a library user.
I'm pretty confident this is what a lot of people here want to actually
have, API wise.
Regarding atomic increment/decrement: does it actually matter that
volatile does not guarantee continuity?
The behaviour of missing key is well-defined. I consider it much better
to provide it than have users create their own poor-mans counter of
get() + set() combination.
Also, a counter on a VolatileCache may actually be useful - e.g. the
counter dropping back to zero is an indicator that eviction started. I
would not assume this useless. Less useful than a counter of a
PinnedCache, yes, but omitting it from VolatileCache is too opinionated.
Regarding Storable values: why are Closures not storable? The RFC says:
"Closure objects are request-local executable state and cannot be
represented as stable shared cache values"
But that's not quite true - all Closures, except for those from non-file
inclusions, like eval(), are effectively available in opcaches shared
memory. And those which are not, could technically be stored too - might
be a bit more expensive, but it's seldom the case. Would need some
custom serialization though. Not supporting them is a choice, but the
reason ("cannot") is the wrong one.
Regarding Attributes: Ah, the big contentious topic?
I'm not sure what to make of them. I get the appeal of a nice attribute
that makes it just work, but it's limited, in sort of subtle ways:
- Refreshing the values ... is impossible? The RFC text says manually
going through the Volatile/PinnedCache APIs with the
'volatile_static_class:' prefix and such is disallowed? - Do we actually need to reserve these (*_static[_class]:) prefixes?
Can't we keep this an implementation detail and make sure that there are
dedicated API methods to properly handle it? - Why do we need dedicated prefixes? (volatile_ and pinned_, that is)
Can't we look the specific class / property up and derive the required
cache internally? - The tracking implementation works by adding a tracking callback to the
hot path of every array modification. Minor, but noticeable cost if
nothing uses these attributes. Significant cost if attributes are used.
Even more significant if writes are alternating (i.e. subsequent writes
access different arrays). That's a total non-starter. Maybe you could do
some hackery with overloading RW access to static properties
specifically to assume dirtied. But the current implementation is a
no-no. I get that this also affects stored objects and such. But, as
said, too invasive/expensive.
I really wonder whether you sat down at any point here and asked
yourself "should I really do this?", given all its complexity here. - Scalar values are also not auto-refreshed, which probably makes for
surprising behavior when trying to increment some counter, and other
requests increment in parallel. I'm not too happy about the developer
experience around this.
Overall, the attributes probably need quite a bit more discussion and
refinement. I think it might be worth splitting it fully off into its
own RFC.
And the implementation will be probably also downsized by more than
half, making it a bit more manageable to review and assess.
The general integration of APCu-like capabilities with separate pinned
and volatile caches make a lot of sense to me.
With some minor refinements, I think I'd like to see this functionality
in PHP!
Thanks for your effort on this RFC,
Bob