[PHP-DEV] [RFC] [Discussion] Serializable closures from constant expressions

Hi,

I’d like to open the discussion on a new RFC:
https://wiki.php.net/rfc/serializable_closures

PHP 8.5 allowed closures in attribute arguments and parameter defaults. These closures are static and capture nothing, yet they cannot be serialized, which silently breaks every serialize()-based metadata cache that meets them.

The RFC proposes to make them serializable as references to their declaration site.

Feedback welcome.

Cheers,
Nicolas

Hi

On 6/10/26 19:02, Nicolas Grekas wrote:

I'd like to open the discussion on a new RFC:
PHP: rfc:serializable_closures

Don't forget to link the discussion thread in the RFC. For your convenience: php.internals: [RFC] [Discussion] Serializable closures from constant expressions

As to the RFC itself: I think it might be useful to split this into two RFCs, similarly to how Volker and I split the initial support for Closures in const-expr into support for Closures and support for first class callables.

Specifically I believe that the proposed serialization format is fragile and the stated security model, which I suspect heavily influenced the design of the serialization format, is probably overly cautious.

I don't think it is a problem to make `unserialize()` a Closure factory, because the created Closure is “inert”. Contrary to arbitrary object unserialization (which will immediately call the deserialization hooks and then later __destruct()), the Closure will not actually do anything unless it is called.

If folks are able to unserialize arbitrary payloads - which is documented to be unsafe - they already have capabilities that are much more powerful than “creating Closures”.

For first class callables specifically, there is a very obvious and stable identifier to use: The underlying function name.

For regular Closures, I don't like how changing something unrelated (namely adding new Closures to a class) can change the meaning of the serialized data. This is not how serialization works right now: The value in question is in full control of its serialization format and is able to “gracefully” support existing serialized payloads using an old format within an unserialization hook (e.g. by including a version number field). This allows to e.g. keep serialized queue jobs working across deploys.

As for the var_export() in the future scope: I think adding support for first class callables to `var_export()` would be a change that can just be done without an RFC. It might be a good first step that might already be helpful to your use case?

Best regards
Tim Düsterhus

Hi,

thanks for having a look

Le lun. 15 juin 2026 à 21:30, Tim Düsterhus <tim@bastelstu.be> a écrit :

Hi

On 6/10/26 19:02, Nicolas Grekas wrote:

I’d like to open the discussion on a new RFC:
https://wiki.php.net/rfc/serializable_closures

Don’t forget to link the discussion thread in the RFC. For your
convenience: https://news-web.php.net/php.internals/131198

As to the RFC itself: I think it might be useful to split this into two
RFCs, similarly to how Volker and I split the initial support for
Closures in const-expr into support for Closures and support for first
class callables.

The 8.5 split worked because closures and FCCs were separable features.
Here it’s one mechanism, the FCC half alone or the anonymous-closure one alone would be just missing its other half.
I’d keep it as one RFC.

Specifically I believe that the proposed serialization format is fragile
and the stated security model, which I suspect heavily influenced the
design of the serialization format, is probably overly cautious.

I don’t think it is a problem to make unserialize() a Closure factory,
because the created Closure is “inert”. Contrary to arbitrary object
unserialization (which will immediately call the deserialization hooks
and then later __destruct()), the Closure will not actually do anything
unless it is called.

If folks are able to unserialize arbitrary payloads - which is
documented to be unsafe - they already have capabilities that are much
more powerful than “creating Closures”.

Creation is inert, but the point of these payloads is to be called. Once it’s called, the only thing that matters is which callables a payload can name.
Declared-set resolution: an attacker can swap one closure the class already declares for another.
Unrestricted name-based: {function: “system”} with attacker-supplied args is a call gadget shipped in the engine, against any app that unserializes untrusted input.
“allowed_classes” exists and 8.6 tightened session defaults precisely because we bound damage despite unserialize being documented unsafe.
So I’d keep the declared-set boundary as required defense-in-depth / hardening.

For first class callables specifically, there is a very obvious and
stable identifier to use: The underlying function name.

For regular Closures, I don’t like how changing something unrelated
(namely adding new Closures to a class) can change the meaning of the
serialized data. This is not how serialization works right now: The
value in question is in full control of its serialization format and is
able to “gracefully” support existing serialized payloads using an old
format within an unserialization hook (e.g. by including a version
number field). This allows to e.g. keep serialized queue jobs working
across deploys.

Agreed: first-class callables now serialize with the function name as identifier, no ordinal involved. The id is member@callable, e.g. $billingAddress@Order::isStrict for #[When(self::isStrict(...))] on that property, $p@strlen for a plain function. The member prefix keeps resolution local to one reflection element instead of scanning the whole class on every cache read (fat classes would pay otherwise), and it gives both closure forms the same staleness rule: a reference is valid while its member and its declaration survive. Adding, removing or reordering anything else in the class changes nothing; renaming the target method fails.

One catch: the idiomatic form references a private helper of the same class, #[When(self::isStrict(…))], and Closure::fromCallable(‘C::priv’) from global scope throws “cannot access private method”. So resolution doesn’t resolve the name directly: it checks if the named member declares that exact reference, then evaluates the declaration in class scope, name as address, declaration as guard.
Private keeps working, {…, “$p@system”} stays rejected because no class declares it.

As for the var_export() in the future scope: I think adding support for
first class callables to var_export() would be a change that can just
be done without an RFC. It might be a good first step that might already
be helpful to your use case?

Not really helpful for the main use cases I gathered, which require full compat with serialize semantics, but yeah, I agree this can be dealt with on its own.

RFC updated!
I tried to reflect all your points in the update.

Cheers,
Nicolas

Le sam. 4 juil. 2026 à 10:47, Nicolas Grekas <nicolas.grekas+php@gmail.com> a écrit :

Hi,

thanks for having a look

Le lun. 15 juin 2026 à 21:30, Tim Düsterhus <tim@bastelstu.be> a écrit :

Hi

On 6/10/26 19:02, Nicolas Grekas wrote:

I’d like to open the discussion on a new RFC:
https://wiki.php.net/rfc/serializable_closures

Don’t forget to link the discussion thread in the RFC. For your
convenience: https://news-web.php.net/php.internals/131198

As to the RFC itself: I think it might be useful to split this into two
RFCs, similarly to how Volker and I split the initial support for
Closures in const-expr into support for Closures and support for first
class callables.

The 8.5 split worked because closures and FCCs were separable features.
Here it’s one mechanism, the FCC half alone or the anonymous-closure one alone would be just missing its other half.
I’d keep it as one RFC.

Specifically I believe that the proposed serialization format is fragile
and the stated security model, which I suspect heavily influenced the
design of the serialization format, is probably overly cautious.

I don’t think it is a problem to make unserialize() a Closure factory,
because the created Closure is “inert”. Contrary to arbitrary object
unserialization (which will immediately call the deserialization hooks
and then later __destruct()), the Closure will not actually do anything
unless it is called.

If folks are able to unserialize arbitrary payloads - which is
documented to be unsafe - they already have capabilities that are much
more powerful than “creating Closures”.

Creation is inert, but the point of these payloads is to be called. Once it’s called, the only thing that matters is which callables a payload can name.
Declared-set resolution: an attacker can swap one closure the class already declares for another.
Unrestricted name-based: {function: “system”} with attacker-supplied args is a call gadget shipped in the engine, against any app that unserializes untrusted input.
“allowed_classes” exists and 8.6 tightened session defaults precisely because we bound damage despite unserialize being documented unsafe.
So I’d keep the declared-set boundary as required defense-in-depth / hardening.

For first class callables specifically, there is a very obvious and
stable identifier to use: The underlying function name.

For regular Closures, I don’t like how changing something unrelated
(namely adding new Closures to a class) can change the meaning of the
serialized data. This is not how serialization works right now: The
value in question is in full control of its serialization format and is
able to “gracefully” support existing serialized payloads using an old
format within an unserialization hook (e.g. by including a version
number field). This allows to e.g. keep serialized queue jobs working
across deploys.

Agreed: first-class callables now serialize with the function name as identifier, no ordinal involved. The id is member@callable, e.g. $billingAddress@Order::isStrict for #[When(self::isStrict(...))] on that property, $p@strlen for a plain function. The member prefix keeps resolution local to one reflection element instead of scanning the whole class on every cache read (fat classes would pay otherwise), and it gives both closure forms the same staleness rule: a reference is valid while its member and its declaration survive. Adding, removing or reordering anything else in the class changes nothing; renaming the target method fails.

One catch: the idiomatic form references a private helper of the same class, #[When(self::isStrict(…))], and Closure::fromCallable(‘C::priv’) from global scope throws “cannot access private method”. So resolution doesn’t resolve the name directly: it checks if the named member declares that exact reference, then evaluates the declaration in class scope, name as address, declaration as guard.
Private keeps working, {…, “$p@system”} stays rejected because no class declares it.

As for the var_export() in the future scope: I think adding support for
first class callables to var_export() would be a change that can just
be done without an RFC. It might be a good first step that might already
be helpful to your use case?

Not really helpful for the main use cases I gathered, which require full compat with serialize semantics, but yeah, I agree this can be dealt with on its own.

RFC updated!
I tried to reflect all your points in the update.

Side-note to all:

I’d also be fine with a limited version of this RFC that’d remove the serialize-related part and that’d keep only the proposed Reflection-based API. This is the very core where engine support is needed. The serialize part would make attributes work seamlessly with backends that use serialize(), but my use cases build on the deepclone/VarExporter extension/components, and those need only reflection.

In case that can help bring a broader consensus.

Nicolas

Le 10/06/2026 à 19:02, Nicolas Grekas a écrit :

Hi,

I'd like to open the discussion on a new RFC:
PHP: rfc:serializable_closures

PHP 8.5 allowed closures in attribute arguments and parameter defaults. These closures are static and capture nothing, yet they cannot be serialized, which silently breaks every serialize()-based metadata cache that meets them.

Would this mean that if you serialize such object (in some cache for example) then change the object default value in code in between, deploy your new code version then load the outdated cache, your object would behave inconsistently, because the default value has been changed, but the loaded object would not honor the new default behavior ?

For caches it's not that much a problem because you mostly clear caches when you deploy, but considering, let's say for example, Symfony messenger messages, which are basically serialize()'d per default, it becomes a major problem.

--

Pierre

Le 10/06/2026 à 19:02, Nicolas Grekas a écrit :

Hi,

I'd like to open the discussion on a new RFC:
PHP: rfc:serializable_closures

PHP 8.5 allowed closures in attribute arguments and parameter defaults. These closures are static and capture nothing, yet they cannot be serialized, which silently breaks every serialize()-based metadata cache that meets them.

The RFC proposes to make them serializable as references to their declaration site.

Feedback welcome.

Cheers,
Nicolas

Please forget my previous email, this was answered in the RFC:

> Unserializing autoloads the class if needed and recreates the closure /as if its constant expression had just been evaluated/: same code, statically scoped to its declaring class (|self::|, private member access and |static::expressionless: behave identically),

Sorry for the noise!

Regards,

--

Pierre

Hi there,

Le sam. 4 juil. 2026 à 11:02, Nicolas Grekas <nicolas.grekas+php@gmail.com> a écrit :

Le sam. 4 juil. 2026 à 10:47, Nicolas Grekas <nicolas.grekas+php@gmail.com> a écrit :

Hi,

thanks for having a look

Le lun. 15 juin 2026 à 21:30, Tim Düsterhus <tim@bastelstu.be> a écrit :

Hi

On 6/10/26 19:02, Nicolas Grekas wrote:

I’d like to open the discussion on a new RFC:
https://wiki.php.net/rfc/serializable_closures

Don’t forget to link the discussion thread in the RFC. For your
convenience: https://news-web.php.net/php.internals/131198

As to the RFC itself: I think it might be useful to split this into two
RFCs, similarly to how Volker and I split the initial support for
Closures in const-expr into support for Closures and support for first
class callables.

The 8.5 split worked because closures and FCCs were separable features.
Here it’s one mechanism, the FCC half alone or the anonymous-closure one alone would be just missing its other half.
I’d keep it as one RFC.

Specifically I believe that the proposed serialization format is fragile
and the stated security model, which I suspect heavily influenced the
design of the serialization format, is probably overly cautious.

I don’t think it is a problem to make unserialize() a Closure factory,
because the created Closure is “inert”. Contrary to arbitrary object
unserialization (which will immediately call the deserialization hooks
and then later __destruct()), the Closure will not actually do anything
unless it is called.

If folks are able to unserialize arbitrary payloads - which is
documented to be unsafe - they already have capabilities that are much
more powerful than “creating Closures”.

Creation is inert, but the point of these payloads is to be called. Once it’s called, the only thing that matters is which callables a payload can name.
Declared-set resolution: an attacker can swap one closure the class already declares for another.
Unrestricted name-based: {function: “system”} with attacker-supplied args is a call gadget shipped in the engine, against any app that unserializes untrusted input.
“allowed_classes” exists and 8.6 tightened session defaults precisely because we bound damage despite unserialize being documented unsafe.
So I’d keep the declared-set boundary as required defense-in-depth / hardening.

For first class callables specifically, there is a very obvious and
stable identifier to use: The underlying function name.

For regular Closures, I don’t like how changing something unrelated
(namely adding new Closures to a class) can change the meaning of the
serialized data. This is not how serialization works right now: The
value in question is in full control of its serialization format and is
able to “gracefully” support existing serialized payloads using an old
format within an unserialization hook (e.g. by including a version
number field). This allows to e.g. keep serialized queue jobs working
across deploys.

Agreed: first-class callables now serialize with the function name as identifier, no ordinal involved. The id is member@callable, e.g. $billingAddress@Order::isStrict for #[When(self::isStrict(...))] on that property, $p@strlen for a plain function. The member prefix keeps resolution local to one reflection element instead of scanning the whole class on every cache read (fat classes would pay otherwise), and it gives both closure forms the same staleness rule: a reference is valid while its member and its declaration survive. Adding, removing or reordering anything else in the class changes nothing; renaming the target method fails.

One catch: the idiomatic form references a private helper of the same class, #[When(self::isStrict(…))], and Closure::fromCallable(‘C::priv’) from global scope throws “cannot access private method”. So resolution doesn’t resolve the name directly: it checks if the named member declares that exact reference, then evaluates the declaration in class scope, name as address, declaration as guard.
Private keeps working, {…, “$p@system”} stays rejected because no class declares it.

As for the var_export() in the future scope: I think adding support for
first class callables to var_export() would be a change that can just
be done without an RFC. It might be a good first step that might already
be helpful to your use case?

Not really helpful for the main use cases I gathered, which require full compat with serialize semantics, but yeah, I agree this can be dealt with on its own.

RFC updated!
I tried to reflect all your points in the update.

Side-note to all:

I’d also be fine with a limited version of this RFC that’d remove the serialize-related part and that’d keep only the proposed Reflection-based API. This is the very core where engine support is needed. The serialize part would make attributes work seamlessly with backends that use serialize(), but my use cases build on the deepclone/VarExporter extension/components, and those need only reflection.

In case that can help bring a broader consensus.

I’d be happy to gather more feedback on this proposal. The PHP 8.6 deadline is approaching fast and while this can wait, it’s not substantial enough to wait one more year IMHO.
Opening a vote without hearing that some consensus exists before is a recipe for failure, so I’d rather send these reminders before opening one.

I get the topic is not as hype as generics or other syntax improvements, but incremental improvements driven by userland needs is still how we built a relevant engine :wink:

Thanks!
Nicolas

Hi

I have given the RFC another read before reading your email and the replies I'm giving below might possibly reflect that.

On 2026-07-04 10:47, Nicolas Grekas wrote:

As to the RFC itself: I think it might be useful to split this into two
RFCs, similarly to how Volker and I split the initial support for
Closures in const-expr into support for Closures and support for first
class callables.

The 8.5 split worked because closures and FCCs were separable features.
Here it's one mechanism, the FCC half alone or the anonymous-closure one
alone would be just missing its other half.
I'd keep it as one RFC.

I made that suggestion, because I believe that building a robust solution for first class callables is much easier than for “full Closures”.

While reading the RFC initially and now the updated version, I got the feeling that it was “overfitted” to solve the specific use case and deployment scenario that you consider a “best practice”, which I feel results in “weird” behavior when one leaves that happy path. The updated version is already better, particularly around first class callables, but I can't say that I'm *happy* with it.

I don't think it is a problem to make `unserialize()` a Closure factory,
because the created Closure is “inert”. Contrary to arbitrary object
unserialization (which will immediately call the deserialization hooks
and then later __destruct()), the Closure will not actually do anything
unless it is called.

If folks are able to unserialize arbitrary payloads - which is
documented to be unsafe - they already have capabilities that are much
more powerful than “creating Closures”.

Creation is inert, but the point of these payloads is to be called. Once
it's called, the only thing that matters is which callables a payload can
name.

Yes, but this requires application code to already be set up to try to perform a function call on unserialized data, which is not a typical situation. The typical “unserialization to RCE” vector looks something like this:

     $sessionData = unserialize($_COOKIE['session']);

where the cookie contains a serialized payload. This looks totally reasonable unless you know that `unserialize()` will already execute arbitrary code by itself. For Closures in the payload to be exploitable you need extra code that is much less likely to be written “by accident” in situations where untrusted data ends up in unserialize.

And then the attacker also needs to control the inputs to the Closure in question for this to actually be exploitable. Even if a Closure created from unserialized data is called, the code performing the call expects a function matching a specific signature, that is unlikely to be matched by “exploitable” functionality. Calling `system()` without any arguments will just fail. And the same is true if `system()` ends up in a place where the caller passes an object as the first parameter.

So I'd keep the declared-set boundary as required defense-in-depth /
hardening.

That said, I think I'd be okay with keeping this defense-in-depth for now, but I think I would then want a serialization format that allows for a clean “forward compatibility” in case we want to relax this later. Specifically I think we should be careful with “reserving” this many top-level keys with specific names in the serialization payload. As an example, the `class` key makes adding support for the future-scope “free-function attributes” unnecessarily complicated, which is part of what I meant by “overfitted to the use-case” above.

An idea that I have not given too much thought would be making the serialization payload a “tagged union” with something like `[get_mangled_object_vars($this), ["const-expr", ['Order', 'billingAddress@0']]]`. This could then be extended to a `[get_mangled_object_vars($this), ["fcc", ["strrev"]]]` or similar. Perhaps seek inspiration from the serialization format for ext/uri or ext/random, which have explicitly been designed to be able to be extended in a backwards compatible fashion and to avoid naming clashes between actual userland properties and internal state.

Agreed: first-class callables now serialize with the function name as
identifier, no ordinal involved. The id is `member@callable`, e.g.
`$billingAddress@Order::isStrict` for `#[When(self::isStrict(...))]` on
that property, `$p@strlen` for a plain function. The member prefix keeps
resolution local to one reflection element instead of scanning the whole
class on every cache read (fat classes would pay otherwise), and it gives
both closure forms the same staleness rule: a reference is valid while its
member and its declaration survive. Adding, removing or reordering anything
else in the class changes nothing; renaming the target method fails.

I wonder if using the function name would also work for regular Closures. I've updated the names for PHP 8.4 to include the name of the declaring scope and line number, which is exactly the information the RFC is already using as a guard as of now.

That said: I understand that accurately identifying “full Closures” is complicated, but I also feel that the line number guard for proper Closures is making serialization payloads fragile across deployments. Adding a single “use” import at the top of the file would break all unserialization - which again feels like the overfitting. I don't have a good suggestion here, which is why I suggested to solve the first class callable and “full Closure” cases separately.

One catch: the idiomatic form references a private helper of the same
class, #[When(self::isStrict(...))], and Closure::fromCallable('C::priv')
from global scope throws "cannot access private method". So resolution
doesn't resolve the name directly: it checks if the named member declares
that exact reference, then evaluates the declaration in class scope, name
as address, declaration as guard.

Yes, unserialization ignores or “trusts” visibility. That's just how it works.

As for the var_export() in the future scope: I think adding support for
first class callables to `var_export()` would be a change that can just
be done without an RFC. It might be a good first step that might already
be helpful to your use case?

Not really helpful for the main use cases I gathered, which require full
compat with serialize semantics, but yeah, I agree this can be dealt with
on its own.

Specifically with regard to `var_export()`, I'm also concerned about the ReflectionFunction::getConstExprId(), ReflectionFunction::getConstExprClass(), and Closure::fromConstExpr() additions, which very much feel like “exposing an implementation detail”. These functions will be part of the public API and documentation, which means that users will come across them. I don't currently see how we can meaningfully document them, since they serve such a narrow use case based on very specific assumptions.

Best regards
Tim Düsterhus

Hi

On 2026-07-04 11:02, Nicolas Grekas wrote:

I'd also be fine with a limited version of this RFC that'd remove the
serialize-related part and that'd keep only the proposed Reflection-based
API. This is the very core where engine support is needed. The serialize
part would make attributes work seamlessly with backends that use
serialize(), but my use cases build on the deepclone/VarExporter
extension/components, and those need only reflection.

In case that can help bring a broader consensus.

As indicated in my previous email, I'm also concerned about the Reflection-based API and don't consider it good language design. Given that the Reflection-based API needs Reflection and the specific constraint of “the serialized payload is only valid until the next deployment”, I also don't see how it would enable anything that you can't already do.

- For public first class callables, just generate a first class callable.
- For private and protected ones generate `(new ReflectionMethod($class, $method))->getClosure()`.
- For full Closures generate the the appropriate Reflection chain accessing the right Closure, e.g. `(new ReflectionProperty($class, $method))->getAttributes()[$attrNo]->getArguments()[$argumentNo]`.

The proposed new API would effectively do the same, and then add the extra safety checks that you, from what I understand, wouldn't need for this use case.

Best regards
Tim Düsterhus

Hi

On 2026-07-07 13:52, Nicolas Grekas wrote:

Opening a vote without hearing that some consensus exists before is a
recipe for failure, so I'd rather send these reminders before opening one.
I get the topic is not as hype as generics or other syntax improvements,
but incremental improvements driven by userland needs is still how we built
a relevant engine :wink:

Despite my concerns regarding the proposal as-written, I can only agree with this specific message. I find this kind of boring building block functionality that you wouldn’t notice because it “just works” to be much more impactful than some of the fancy headline features that make rounds on social media. But the “just works” is a constraint, as with any other RFC we need to get it *right* due to backwards compatibility considerations. And of course it shouldn't just work for one situation, but be reasonably generically applicable. This is really not something that should be discussed between just Nicolas and me :slight_smile:

Best regards
Tim Düsterhus

Thanks Tim,

replying to both your messages in one go:

Le mer. 8 juil. 2026 à 21:28, Tim Düsterhus <tim@bastelstu.be> a écrit :

Hi

I have given the RFC another read before reading your email and the
replies I’m giving below might possibly reflect that.

On 2026-07-04 10:47, Nicolas Grekas wrote:

As to the RFC itself: I think it might be useful to split this into
two
RFCs, similarly to how Volker and I split the initial support for
Closures in const-expr into support for Closures and support for first
class callables.

The 8.5 split worked because closures and FCCs were separable features.
Here it’s one mechanism, the FCC half alone or the anonymous-closure
one
alone would be just missing its other half.
I’d keep it as one RFC.

I made that suggestion, because I believe that building a robust
solution for first class callables is much easier than for “full
Closures”.

While reading the RFC initially and now the updated version, I got the
feeling that it was “overfitted” to solve the specific use case and
deployment scenario that you consider a “best practice”, which I feel
results in “weird” behavior when one leaves that happy path. The updated
version is already better, particularly around first class callables,
but I can’t say that I’m happy with it.

I don’t think it is a problem to make unserialize() a Closure
factory,
because the created Closure is “inert”. Contrary to arbitrary object
unserialization (which will immediately call the deserialization hooks
and then later __destruct()), the Closure will not actually do
anything
unless it is called.

If folks are able to unserialize arbitrary payloads - which is
documented to be unsafe - they already have capabilities that are much
more powerful than “creating Closures”.

Creation is inert, but the point of these payloads is to be called.
Once
it’s called, the only thing that matters is which callables a payload
can
name.

Yes, but this requires application code to already be set up to try to
perform a function call on unserialized data, which is not a typical
situation. The typical “unserialization to RCE” vector looks something
like this:

$sessionData = unserialize($_COOKIE[‘session’]);

where the cookie contains a serialized payload. This looks totally
reasonable unless you know that unserialize() will already execute
arbitrary code by itself. For Closures in the payload to be exploitable
you need extra code that is much less likely to be written “by accident”
in situations where untrusted data ends up in unserialize.

And then the attacker also needs to control the inputs to the Closure in
question for this to actually be exploitable. Even if a Closure created
from unserialized data is called, the code performing the call expects a
function matching a specific signature, that is unlikely to be matched
by “exploitable” functionality. Calling system() without any arguments
will just fail. And the same is true if system() ends up in a place
where the caller passes an object as the first parameter.

So I’d keep the declared-set boundary as required defense-in-depth /
hardening.

That said, I think I’d be okay with keeping this defense-in-depth for
now, but I think I would then want a serialization format that allows
for a clean “forward compatibility” in case we want to relax this later.
Specifically I think we should be careful with “reserving” this many
top-level keys with specific names in the serialization payload. As an
example, the class key makes adding support for the future-scope
“free-function attributes” unnecessarily complicated, which is part of
what I meant by “overfitted to the use-case” above.

Happy we converged on this defense-in-depth aspect, I wouldn’t be comfortable with an unbounded allowance for closures.

An idea that I have not given too much thought would be making the
serialization payload a “tagged union” with something like
[get_mangled_object_vars($this), ["const-expr", ['Order', 'billingAddress@0']]]. This could then be extended to a
[get_mangled_object_vars($this), ["fcc", ["strrev"]]] or similar.
Perhaps seek inspiration from the serialization format for ext/uri or
ext/random, which have explicitly been designed to be able to be
extended in a backwards compatible fashion and to avoid naming clashes
between actual userland properties and internal state.

Thanks for the idea, I made it to the RFC + implementation.

Agreed: first-class callables now serialize with the function name as
identifier, no ordinal involved. The id is member@callable, e.g.
$billingAddress@Order::isStrict for #[When(self::isStrict(...))] on
that property, $p@strlen for a plain function. The member prefix
keeps
resolution local to one reflection element instead of scanning the
whole
class on every cache read (fat classes would pay otherwise), and it
gives
both closure forms the same staleness rule: a reference is valid while
its
member and its declaration survive. Adding, removing or reordering
anything
else in the class changes nothing; renaming the target method fails.

I wonder if using the function name would also work for regular
Closures. I’ve updated the names for PHP 8.4 to include the name of the
declaring scope and line number, which is exactly the information the
RFC is already using as a guard as of now.

That said: I understand that accurately identifying “full Closures” is
complicated, but I also feel that the line number guard for proper
Closures is making serialization payloads fragile across deployments.
Adding a single “use” import at the top of the file would break all
unserialization - which again feels like the overfitting. I don’t have a
good suggestion here, which is why I suggested to solve the first class
callable and “full Closure” cases separately.

I tweaked the line number to make it relative to the class, so that adding a use doesn’t invalidate payloads.
What we’re building here is an addressing scheme and it’s part of the consensus we need to have in this RFC.
The one proposed here is quite efficient to implement and effective to achieve the goal.

Also, no need for something as robust as a named identifier, it’s really fine that these payloads stay valid only for the code revision they were generated with.

One catch: the idiomatic form references a private helper of the same
class, #[When(self::isStrict(…))], and
Closure::fromCallable(‘C::priv’)
from global scope throws “cannot access private method”. So resolution
doesn’t resolve the name directly: it checks if the named member
declares
that exact reference, then evaluates the declaration in class scope,
name
as address, declaration as guard.

Yes, unserialization ignores or “trusts” visibility. That’s just how it
works.

As for the var_export() in the future scope: I think adding support
for
first class callables to var_export() would be a change that can
just
be done without an RFC. It might be a good first step that might
already
be helpful to your use case?

Not really helpful for the main use cases I gathered, which require
full
compat with serialize semantics, but yeah, I agree this can be dealt
with
on its own.

Specifically with regard to var_export(), I’m also concerned about the
ReflectionFunction::getConstExprId(),
ReflectionFunction::getConstExprClass(), and Closure::fromConstExpr()
additions, which very much feel like “exposing an implementation
detail”. These functions will be part of the public API and
documentation, which means that users will come across them. I don’t
currently see how we can meaningfully document them, since they serve
such a narrow use case based on very specific assumptions.

see below

Le mer. 8 juil. 2026 à 21:39, Tim Düsterhus <tim@bastelstu.be> a écrit :

On 2026-07-04 11:02, Nicolas Grekas wrote:

I’d also be fine with a limited version of this RFC that’d remove the
serialize-related part and that’d keep only the proposed
Reflection-based
API. This is the very core where engine support is needed. The
serialize
part would make attributes work seamlessly with backends that use
serialize(), but my use cases build on the deepclone/VarExporter
extension/components, and those need only reflection.

In case that can help bring a broader consensus.

As indicated in my previous email, I’m also concerned about the
Reflection-based API and don’t consider it good language design. Given
that the Reflection-based API needs Reflection and the specific
constraint of “the serialized payload is only valid until the next
deployment”, I also don’t see how it would enable anything that you
can’t already do.

  • For public first class callables, just generate a first class
    callable.
  • For private and protected ones generate (new ReflectionMethod($class, $method))->getClosure().
  • For full Closures generate the the appropriate Reflection chain
    accessing the right Closure, e.g. (new ReflectionProperty($class, $method))->getAttributes()[$attrNo]->getArguments()[$argumentNo].

What’s missing from your analysis is the “provenance” metadata that reflection currently gives no access to. I even had to add a flag in the C struct to persist this. Nothing today distinguishes a closure that a class declared in a constant expression from one built at runtime, yet that is precisely the question a cache layer must answer before it may reference a closure at all.

The userland deepclone extension and its pure-PHP polyfill are the existence proof: both need this and I had to instrument ‘‘ReflectionAttribute’’ by overloading the class in the ext, and the polyfill needs complex and fragile heuristic machinery. The RFC would make these go away.

The proposed new API would effectively do the same, and then add the
extra safety checks that you, from what I understand, wouldn’t need for
this use case

RFC updated.

Cheers,
Nicolas

Le dim. 12 juil. 2026 à 00:13, Nicolas Grekas <nicolas.grekas+php@gmail.com> a écrit :

Thanks Tim,

replying to both your messages in one go:

Le mer. 8 juil. 2026 à 21:28, Tim Düsterhus <tim@bastelstu.be> a écrit :

Hi

I have given the RFC another read before reading your email and the
replies I’m giving below might possibly reflect that.

On 2026-07-04 10:47, Nicolas Grekas wrote:

As to the RFC itself: I think it might be useful to split this into
two
RFCs, similarly to how Volker and I split the initial support for
Closures in const-expr into support for Closures and support for first
class callables.

The 8.5 split worked because closures and FCCs were separable features.
Here it’s one mechanism, the FCC half alone or the anonymous-closure
one
alone would be just missing its other half.
I’d keep it as one RFC.

I made that suggestion, because I believe that building a robust
solution for first class callables is much easier than for “full
Closures”.

While reading the RFC initially and now the updated version, I got the
feeling that it was “overfitted” to solve the specific use case and
deployment scenario that you consider a “best practice”, which I feel
results in “weird” behavior when one leaves that happy path. The updated
version is already better, particularly around first class callables,
but I can’t say that I’m happy with it.

I don’t think it is a problem to make unserialize() a Closure
factory,
because the created Closure is “inert”. Contrary to arbitrary object
unserialization (which will immediately call the deserialization hooks
and then later __destruct()), the Closure will not actually do
anything
unless it is called.

If folks are able to unserialize arbitrary payloads - which is
documented to be unsafe - they already have capabilities that are much
more powerful than “creating Closures”.

Creation is inert, but the point of these payloads is to be called.
Once
it’s called, the only thing that matters is which callables a payload
can
name.

Yes, but this requires application code to already be set up to try to
perform a function call on unserialized data, which is not a typical
situation. The typical “unserialization to RCE” vector looks something
like this:

$sessionData = unserialize($_COOKIE[‘session’]);

where the cookie contains a serialized payload. This looks totally
reasonable unless you know that unserialize() will already execute
arbitrary code by itself. For Closures in the payload to be exploitable
you need extra code that is much less likely to be written “by accident”
in situations where untrusted data ends up in unserialize.

And then the attacker also needs to control the inputs to the Closure in
question for this to actually be exploitable. Even if a Closure created
from unserialized data is called, the code performing the call expects a
function matching a specific signature, that is unlikely to be matched
by “exploitable” functionality. Calling system() without any arguments
will just fail. And the same is true if system() ends up in a place
where the caller passes an object as the first parameter.

So I’d keep the declared-set boundary as required defense-in-depth /
hardening.

That said, I think I’d be okay with keeping this defense-in-depth for
now, but I think I would then want a serialization format that allows
for a clean “forward compatibility” in case we want to relax this later.
Specifically I think we should be careful with “reserving” this many
top-level keys with specific names in the serialization payload. As an
example, the class key makes adding support for the future-scope
“free-function attributes” unnecessarily complicated, which is part of
what I meant by “overfitted to the use-case” above.

Happy we converged on this defense-in-depth aspect, I wouldn’t be comfortable with an unbounded allowance for closures.

An idea that I have not given too much thought would be making the
serialization payload a “tagged union” with something like
[get_mangled_object_vars($this), ["const-expr", ['Order', 'billingAddress@0']]]. This could then be extended to a
[get_mangled_object_vars($this), ["fcc", ["strrev"]]] or similar.
Perhaps seek inspiration from the serialization format for ext/uri or
ext/random, which have explicitly been designed to be able to be
extended in a backwards compatible fashion and to avoid naming clashes
between actual userland properties and internal state.

Thanks for the idea, I made it to the RFC + implementation.

Agreed: first-class callables now serialize with the function name as
identifier, no ordinal involved. The id is member@callable, e.g.
$billingAddress@Order::isStrict for #[When(self::isStrict(...))] on
that property, $p@strlen for a plain function. The member prefix
keeps
resolution local to one reflection element instead of scanning the
whole
class on every cache read (fat classes would pay otherwise), and it
gives
both closure forms the same staleness rule: a reference is valid while
its
member and its declaration survive. Adding, removing or reordering
anything
else in the class changes nothing; renaming the target method fails.

I wonder if using the function name would also work for regular
Closures. I’ve updated the names for PHP 8.4 to include the name of the
declaring scope and line number, which is exactly the information the
RFC is already using as a guard as of now.

That said: I understand that accurately identifying “full Closures” is
complicated, but I also feel that the line number guard for proper
Closures is making serialization payloads fragile across deployments.
Adding a single “use” import at the top of the file would break all
unserialization - which again feels like the overfitting. I don’t have a
good suggestion here, which is why I suggested to solve the first class
callable and “full Closure” cases separately.

I tweaked the line number to make it relative to the class, so that adding a use doesn’t invalidate payloads.
What we’re building here is an addressing scheme and it’s part of the consensus we need to have in this RFC.
The one proposed here is quite efficient to implement and effective to achieve the goal.

Also, no need for something as robust as a named identifier, it’s really fine that these payloads stay valid only for the code revision they were generated with.

Following an off-list review + suggestion by Arnaud, I added a hash of closures’s body to the identifier.
This hash serves as a staleness check and replaces the line number.
It’s stored at compile-time in the AST, and compared when unserializing.
The site@rank next to the hash remains so that polyfill/ext can be written to help transitioning.

One catch: the idiomatic form references a private helper of the same
class, #[When(self::isStrict(…))], and
Closure::fromCallable(‘C::priv’)
from global scope throws “cannot access private method”. So resolution
doesn’t resolve the name directly: it checks if the named member
declares
that exact reference, then evaluates the declaration in class scope,
name
as address, declaration as guard.

Yes, unserialization ignores or “trusts” visibility. That’s just how it
works.

As for the var_export() in the future scope: I think adding support
for
first class callables to var_export() would be a change that can
just
be done without an RFC. It might be a good first step that might
already
be helpful to your use case?

Not really helpful for the main use cases I gathered, which require
full
compat with serialize semantics, but yeah, I agree this can be dealt
with
on its own.

Specifically with regard to var_export(), I’m also concerned about the
ReflectionFunction::getConstExprId(),
ReflectionFunction::getConstExprClass(), and Closure::fromConstExpr()
additions, which very much feel like “exposing an implementation
detail”. These functions will be part of the public API and
documentation, which means that users will come across them. I don’t
currently see how we can meaningfully document them, since they serve
such a narrow use case based on very specific assumptions.

see below

Le mer. 8 juil. 2026 à 21:39, Tim Düsterhus <tim@bastelstu.be> a écrit :

On 2026-07-04 11:02, Nicolas Grekas wrote:

I’d also be fine with a limited version of this RFC that’d remove the
serialize-related part and that’d keep only the proposed
Reflection-based
API. This is the very core where engine support is needed. The
serialize
part would make attributes work seamlessly with backends that use
serialize(), but my use cases build on the deepclone/VarExporter
extension/components, and those need only reflection.

In case that can help bring a broader consensus.

As indicated in my previous email, I’m also concerned about the
Reflection-based API and don’t consider it good language design. Given
that the Reflection-based API needs Reflection and the specific
constraint of “the serialized payload is only valid until the next
deployment”, I also don’t see how it would enable anything that you
can’t already do.

  • For public first class callables, just generate a first class
    callable.
  • For private and protected ones generate (new ReflectionMethod($class, $method))->getClosure().
  • For full Closures generate the the appropriate Reflection chain
    accessing the right Closure, e.g. (new ReflectionProperty($class, $method))->getAttributes()[$attrNo]->getArguments()[$argumentNo].

What’s missing from your analysis is the “provenance” metadata that reflection currently gives no access to. I even had to add a flag in the C struct to persist this. Nothing today distinguishes a closure that a class declared in a constant expression from one built at runtime, yet that is precisely the question a cache layer must answer before it may reference a closure at all.

The userland deepclone extension and its pure-PHP polyfill are the existence proof: both need this and I had to instrument ‘‘ReflectionAttribute’’ by overloading the class in the ext, and the polyfill needs complex and fragile heuristic machinery. The RFC would make these go away.

The proposed new API would effectively do the same, and then add the
extra safety checks that you, from what I understand, wouldn’t need for
this use case

RFC updated.

Cheers,
Nicolas

Hi Nicolas

On 10.06.26 19:02, Nicolas Grekas wrote:

I'd like to open the discussion on a new RFC:
PHP: rfc:serializable_closures

Thank you for your RFC. Sorry for being late to the party.

I'm trying to understand the premise of the RFC. Why does Symfony need to cache attributes at all? Attribute construction is cheap, at least when compared to restoring them using unserialize() or even Symfony's VarExporter. Is this an artifact from the doc block attributes era when parsing them was expensive, or is there a practical reason today?

I found the RFC quite hard to read. It uses a lot of ambiguous terminology, sentences are long and rarely explained with an example. It's also very technical, though maybe necessarily so.

> When the class's source changes, a stored reference either stops resolving or is rejected by the hash check; both throw an Exception on unserialize(), which cache layers already treat as a miss.

I suppose Symfony handles unserialize() errors gracefully, but an unserialize() call today will not fail unless an obvious BC break was made, e.g. by removing a serialized class. This is much more predictable than shifting some elements around or tweaking a closure implementation. I'm not convinced all caching layers today handle errors gracefully.

> Security model

IMO the security argument for unserialize() is redundant. unserialize() is documented to be unsafe when called with unsanitized inputs. For php-src, we close all security reports related to unserialize() as invalid.

> Do not pass untrusted user input to unserialize() regardless of the options value of allowed_classes. Unserialization can result in code being loaded and executed due to object instantiation and autoloading, and a malicious user may be able to exploit this.

Hence, I think security is a good reason for making the serialized format more complex than it needs to be.

The format is very complex and deals with a ton of edge cases, e.g. handling so many attribute target types. It's mostly hidden from users, but not completely (referring to the reflection API).

The implementation is also very large and complex.

Overall, I'm sadly not in favor of this RFC.

Ilija

Thanks for having a look

Le mer. 15 juil. 2026 à 00:20, Ilija Tovilo <tovilo.ilija@gmail.com> a écrit :

Hi Nicolas

On 10.06.26 19:02, Nicolas Grekas wrote:

I’d like to open the discussion on a new RFC:
https://wiki.php.net/rfc/serializable_closures

Thank you for your RFC. Sorry for being late to the party.

I’m trying to understand the premise of the RFC. Why does Symfony need
to cache attributes at all? Attribute construction is cheap, at least
when compared to restoring them using unserialize() or even Symfony’s
VarExporter. Is this an artifact from the doc block attributes era when
parsing them was expensive, or is there a practical reason today?

Attributes are one metadata source among several, in systems designed around loaders: Doctrine entity metadata, Symfony Serializer mappings, Validator constraints, to name a few widely used ones. Loaders read XML, YAML, phpdoc in its day, attributes now, and produce a common representation, merged and indexed along the way. That derived representation is what goes into cache layers or gets dumped as PHP (a cache layer too).

So the comparison is not newInstance() vs unserialize() for one attribute: it is re-running the whole load+merge+index pipeline on every request vs one cache fetch. https://github.com/symfony/symfony/issues/63228 describes how closures in attributes disrupt this: the engine gives no way to put them in a cache (via serialize() or exported PHP), so affected classes fall back to runtime extraction. What you suggest is what is implemented today, and it is the measured slow path. This RFC closes the gap by letting the already-optimized representation be cached.

I found the RFC quite hard to read. It uses a lot of ambiguous
terminology, sentences are long and rarely explained with an example.
It’s also very technical, though maybe necessarily so.

Concrete pointers to unclear spots would help, I’ll happily rework them. The short description on the PR may be a better entry point: https://github.com/php/php-src/pull/22716. The RFC is long because it maps the problem space and the alternatives I explored; that context felt necessary to judge the choices.

When the class’s source changes, a stored reference either stops
resolving or is rejected by the hash check; both throw an Exception on
unserialize(), which cache layers already treat as a miss.

I suppose Symfony handles unserialize() errors gracefully, but an
unserialize() call today will not fail unless an obvious BC break was
made, e.g. by removing a serialized class. This is much more predictable
than shifting some elements around or tweaking a closure implementation.
I’m not convinced all caching layers today handle errors gracefully.

The failure set is narrower than that. References are scoped to their declaring element and carry a hash of the closure’s code: shifting elements around, adding methods, reordering, moving the class in the file, none of that invalidates anything. A reference fails when its own closure’s code changed, when closures were added/removed in its own element, or when the element or class was renamed. And when the closure’s code changed, failing is the correct outcome: the cached metadata was derived from the old code, so the entry must be rebuilt, not partially reused.

Also, today’s behavior on the comparable event (a renamed class) is arguably worse than an exception: __PHP_Incomplete_Class propagates silently until first use. Caches cannot return data they fail to restore per PSR-6/16; treating a failed unserialize as a miss is how conforming implementations behave.

Security model

IMO the security argument for unserialize() is redundant. unserialize()
is documented to be unsafe when called with unsanitized inputs. For
php-src, we close all security reports related to unserialize() as invalid.

https://www.php.net/manual/en/function.unserialize.php

Do not pass untrusted user input to unserialize() regardless of the
options value of allowed_classes. Unserialization can result in code
being loaded and executed due to object instantiation and autoloading,
and a malicious user may be able to exploit this.

Hence, I think security is a good reason for making the serialized
format more complex than it needs to be.

We did add allowed_classes, and we do defer __wakeup()/__unserialize() calls until the whole payload is reconstructed, both because some gadget surfaces were too risky despite that doc note. Security is not black and white; hardening is about bounding consequences when users err. In Symfony we also reject gadget chains as non-CVE, and we still fix them as hardening. See https://github.com/ambionics/phpggc/tree/master/gadgetchains for how effective chains are in practice.

The magnitude here is specific. Today a chain needs app-specific gadget classes; that is why phpggc is a per-framework catalog. Name-based closure unserialization would ship a universal, app-independent gadget in the engine: name system, feed args from the same payload, done. The declared-set boundary reduces that to swapping one closure a class already declares for another.

I won’t commit to an RFC that turns every serialized payload into that kind of gadget, and I don’t think we should, either.

That said, I offered to split the RFC and drop the serialize part from the first one. The reflection API is the piece Symfony needs most: with it, symfony/cache can export the closures itself for any PSR-6/16 consumer. What only the engine can provide is provenance, “is this closure declared by an attribute, and which one”. I tried to build it in userland and in an extension; the extension has to instrument ReflectionAttribute and mirror private engine structs, and the result is still an approximation.

The format is very complex and deals with a ton of edge cases, e.g.
handling so many attribute target types. It’s mostly hidden from users,
but not completely (referring to the reflection API).

The implementation is also very large and complex.

The target types are the ones the language gives attributes; covering fewer would leave arbitrary holes (“this closure serializes, that one doesn’t, because it hangs on a constant”). On size: two flag lines in the compiler, everything else in one file, no new persisted state, no opcache changes.

Overall, I’m sadly not in favor of this RFC.

I hope that’s not the last word, because the problem is real and only the engine can address it; referencing closures found in attributes is generic, not a Symfony quirk. Would the reflection-only subset address your complexity concern? That would help me decide whether to reshape the proposal or its wording.

Nicolas

Dear all,

Le mer. 15 juil. 2026, 17:52, Nicolas Grekas <nicolas.grekas+php@gmail.com> a écrit :

Thanks for having a look

Le mer. 15 juil. 2026 à 00:20, Ilija Tovilo <tovilo.ilija@gmail.com> a écrit :

Hi Nicolas

On 10.06.26 19:02, Nicolas Grekas wrote:

I’d like to open the discussion on a new RFC:
https://wiki.php.net/rfc/serializable_closures

Thank you for your RFC. Sorry for being late to the party.

I’m trying to understand the premise of the RFC. Why does Symfony need
to cache attributes at all? Attribute construction is cheap, at least
when compared to restoring them using unserialize() or even Symfony’s
VarExporter. Is this an artifact from the doc block attributes era when
parsing them was expensive, or is there a practical reason today?

Attributes are one metadata source among several, in systems designed around loaders: Doctrine entity metadata, Symfony Serializer mappings, Validator constraints, to name a few widely used ones. Loaders read XML, YAML, phpdoc in its day, attributes now, and produce a common representation, merged and indexed along the way. That derived representation is what goes into cache layers or gets dumped as PHP (a cache layer too).

So the comparison is not newInstance() vs unserialize() for one attribute: it is re-running the whole load+merge+index pipeline on every request vs one cache fetch. https://github.com/symfony/symfony/issues/63228 describes how closures in attributes disrupt this: the engine gives no way to put them in a cache (via serialize() or exported PHP), so affected classes fall back to runtime extraction. What you suggest is what is implemented today, and it is the measured slow path. This RFC closes the gap by letting the already-optimized representation be cached.

I found the RFC quite hard to read. It uses a lot of ambiguous
terminology, sentences are long and rarely explained with an example.
It’s also very technical, though maybe necessarily so.

Concrete pointers to unclear spots would help, I’ll happily rework them. The short description on the PR may be a better entry point: https://github.com/php/php-src/pull/22716. The RFC is long because it maps the problem space and the alternatives I explored; that context felt necessary to judge the choices.

When the class’s source changes, a stored reference either stops
resolving or is rejected by the hash check; both throw an Exception on
unserialize(), which cache layers already treat as a miss.

I suppose Symfony handles unserialize() errors gracefully, but an
unserialize() call today will not fail unless an obvious BC break was
made, e.g. by removing a serialized class. This is much more predictable
than shifting some elements around or tweaking a closure implementation.
I’m not convinced all caching layers today handle errors gracefully.

The failure set is narrower than that. References are scoped to their declaring element and carry a hash of the closure’s code: shifting elements around, adding methods, reordering, moving the class in the file, none of that invalidates anything. A reference fails when its own closure’s code changed, when closures were added/removed in its own element, or when the element or class was renamed. And when the closure’s code changed, failing is the correct outcome: the cached metadata was derived from the old code, so the entry must be rebuilt, not partially reused.

Also, today’s behavior on the comparable event (a renamed class) is arguably worse than an exception: __PHP_Incomplete_Class propagates silently until first use. Caches cannot return data they fail to restore per PSR-6/16; treating a failed unserialize as a miss is how conforming implementations behave.

Security model

IMO the security argument for unserialize() is redundant. unserialize()
is documented to be unsafe when called with unsanitized inputs. For
php-src, we close all security reports related to unserialize() as invalid.

https://www.php.net/manual/en/function.unserialize.php

Do not pass untrusted user input to unserialize() regardless of the
options value of allowed_classes. Unserialization can result in code
being loaded and executed due to object instantiation and autoloading,
and a malicious user may be able to exploit this.

Hence, I think security is a good reason for making the serialized
format more complex than it needs to be.

We did add allowed_classes, and we do defer __wakeup()/__unserialize() calls until the whole payload is reconstructed, both because some gadget surfaces were too risky despite that doc note. Security is not black and white; hardening is about bounding consequences when users err. In Symfony we also reject gadget chains as non-CVE, and we still fix them as hardening. See https://github.com/ambionics/phpggc/tree/master/gadgetchains for how effective chains are in practice.

The magnitude here is specific. Today a chain needs app-specific gadget classes; that is why phpggc is a per-framework catalog. Name-based closure unserialization would ship a universal, app-independent gadget in the engine: name system, feed args from the same payload, done. The declared-set boundary reduces that to swapping one closure a class already declares for another.

I won’t commit to an RFC that turns every serialized payload into that kind of gadget, and I don’t think we should, either.

That said, I offered to split the RFC and drop the serialize part from the first one. The reflection API is the piece Symfony needs most: with it, symfony/cache can export the closures itself for any PSR-6/16 consumer. What only the engine can provide is provenance, “is this closure declared by an attribute, and which one”. I tried to build it in userland and in an extension; the extension has to instrument ReflectionAttribute and mirror private engine structs, and the result is still an approximation.

The format is very complex and deals with a ton of edge cases, e.g.
handling so many attribute target types. It’s mostly hidden from users,
but not completely (referring to the reflection API).

The implementation is also very large and complex.

The target types are the ones the language gives attributes; covering fewer would leave arbitrary holes (“this closure serializes, that one doesn’t, because it hangs on a constant”). On size: two flag lines in the compiler, everything else in one file, no new persisted state, no opcache changes.

Overall, I’m sadly not in favor of this RFC.

I hope that’s not the last word, because the problem is real and only the engine can address it; referencing closures found in attributes is generic, not a Symfony quirk. Would the reflection-only subset address your complexity concern? That would help me decide whether to reshape the proposal or its wording.

Nicolas

After sleeping on this for a few days, I worked on v0.3 of this RFC.

Unlike was I suggested previously, I didn’t reduce the proposal to Reflection only but to the other way: I removed reflection-related changes and kept only the serialize() part.

This makes things simpler and solve the generic case in a better way: while reflection would have helped the implementation I already built, I’m mostly interested in providing a solution that doesn’t require any dependency. The problem is generic, so should be the solution.

serialize() is the one feature that most/all cache systems are built on, so that’s the place that needs the improvement.

I also reworded the body of the RFC to hopefully address Ilia’s concerns about it, which others might share.

I’m looking forward to new feedback on this new version.

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

Cheers,
Nicolas