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
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.
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
I'd like to open the discussion on a new RFC:
https://wiki.php.net/rfc/serializable_closuresDon't forget to link the discussion thread in the RFC. For your
convenience: https://news-web.php.net/php.internals/131198As 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 tovar_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
I'd like to open the discussion on a new RFC:
https://wiki.php.net/rfc/serializable_closuresDon't forget to link the discussion thread in the RFC. For your
convenience: https://news-web.php.net/php.internals/131198As 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 ismember@callable, e.g.
$billingAddress@Order::isStrictfor#[When(self::isStrict(...))]on
that property,$p@strlenfor 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 tovar_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:
https://wiki.php.net/rfc/serializable_closuresPHP 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:
https://wiki.php.net/rfc/serializable_closuresPHP 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::| behave identically),
Sorry for the noise!
Regards,
--
Pierre