PHP 8.5 allowed closures inside constant expressions: anonymous static function () {} and
first-class callables foo(...), in attribute arguments and parameter default values
(Closures in constant expressions,
First-class callables in constant expressions). By
design they capture nothing, are never bound and are never rebound, so such a closure is fully
described by where it is declared.
serialize() nonetheless refuses every closure, and that breaks a common setup. Metadata systems
read a class's attributes once, turn them into an optimized representation and cache it through
serialize() into a PSR-6 pool: Doctrine for entity mappings, the Symfony Validator for its
constraint metadata, the Serializer for its class metadata. An attribute that carries a closure
makes that representation unserializable:
class Order { #[Assert\Callback(static function (string $value, ExecutionContextInterface $context) { // ...custom validation... })] public string $billingAddress; }
serialize() throws on the cached metadata for Order, the cache layer catches it, and the
metadata is rebuilt from reflection on every request. Nothing fails loudly; the application only gets
slower. This is symfony#63228, and it is not a
framework bug: it hits any code that caches metadata derived from closure-carrying attributes, which
was a main use case of the 8.5 RFCs.
The problem is generic, and serialize() is the generic way caches store data. Today a framework
can only work around it by wrapping every closure in a small object that remembers “attribute X,
argument Y of class Z” and re-runs reflection on first use. That works, but that wrapper is a
dependency the application must carry, and every framework builds and maintains its own version of
one simple fact that the engine already knows: this closure is the one declared right there.
That machinery already exists in userland: the ext-deepclone extension and a pure-PHP polyfill
already do this, each carrying its own implementation of that one fact. The engine is the only
layer that can teach serialize() about these closures once, for the whole ecosystem, with no
added dependency, and let frameworks drop that code.
This RFC makes such closures serializable as a reference to their declaration site.
unserialize() re-evaluates that declaration and returns an equivalent closure. No code travels
in the payload, and a payload can only ever name a closure that the reader's own classes already
declare. Closures with state, and closures created at runtime, stay non-serializable, with the same
error as today.
The extension and polyfill prove the design works and is wanted, but they cannot be where it ends:
serialize() is the contract. Caches store data with serialize(), which still throws on a closure. The userland tools only help code that adopts their deepclone_to_array() API; making serialize($closure) itself work reaches every existing cache with no adoption, and only the engine can do that.Doing it once in the engine replaces this duplicated, partly fragile machinery with a single canonical implementation that the tools above can defer to.
A closure declared in a class's constant expressions serializes to its declaration site: the declaring class, the element that holds it, and its place in that element.
$closure = (new ReflectionProperty(Order::class, 'billingAddress')) ->getAttributes()[0]->getArguments()[0]; unserialize(serialize($closure))(); // behaves exactly like $closure() $closure->__serialize(); // [ [], ['const-expr', ['Order', '$billingAddress', 0, 1244831918]] ]
Unserializing autoloads the class and recreates the closure as if its constant expression had
just been evaluated: same code, statically scoped to the declaring class (self::, private
access and static:: all behave identically), no bound $this, fresh static variables, a
new instance.
Two forms are covered:
static function () {}): the key is the closure's position within its declaring element, guarded by a hash of its code.strlen(...), self::isStrict(...), Validators::check(...)): the key is the callable's name, with no hash since a name does not change on its own. Late static binding is kept and is visible in the name (Order::isStrict, or the parent class for a parent:: reference). Any visibility works, because resolving re-runs the declaration's own access checks: a private helper used from an attribute of its own class round-trips.Serializable positions: attribute arguments (of the class, its constants, properties, property hooks, methods and parameters) and parameter default values, including closures nested in arrays or in further closures declared there. Class constant values and property default values are excluded: the engine frees those initializer expressions after first evaluation, so their sites are not reliably addressable (see Future Scope).
Stays non-serializable, unchanged, with the current Serialization of 'Closure' is not
allowed: any closure that carries state or is created at runtime.
| Closure kind | Why |
|---|---|
| Anonymous and arrow functions declared in function bodies | Capture variables and $this |
Named callables created at runtime (foo(...) in a body, Closure::fromCallable(), ReflectionMethod::getClosure()) | No declaration site; by name they would make unserialize() a factory over the whole code base |
Bound or rebound closures, closures made from __call()/__callStatic() | Carry object state, or have no real declaration behind them |
| Const-expr closures in class constant values, property defaults, or attributes of free functions / anonymous classes | Site not addressable (see Future Scope) |
The boundary is declaration, not syntax: self::check(...) serializes in an attribute
and refuses in a method body, because only the former is something the class declares about itself.
The payload is a tagged union [ <object properties>, [ <tag>, <reference> ] ], matching
ext/uri and ext/random: a Closure has no properties, the “const-expr” tag names the
reference kind, and an older runtime rejects an unknown tag cleanly. The reference itself is four
fields, [class, site, key, hash], derived from the compiled class alone, so every process
running the same source computes the same reference, with or without opcache.
site names the declaring element: “” for the class, a constant or enum-case name, $prop
for a property, $prop::get() / $prop::set() for a hook, name() for a method. key is
the closure's position in that element (an integer) for an anonymous closure, or the callable's name
for a first-class callable. Because the reference belongs to one element, editing one element never
changes the numbering of another, and resolution walks only the named element.
hash guards an anonymous position: a hash of the closure's code computed at compile time (it
ignores whitespace and comments, and opcache stores it at no extra cost). A position alone can come
to point at a different closure after an ordinary edit, for example reordering two attributes:
#[A(static fn () => 1)] // ['$p', 0] #[B(static fn () => 2)] // ['$p', 1] -- swap the two, and ['$p', 0] now names B's closure public string $p;
On resolution the hash is checked; if it does not match, unserialize() throws instead of
returning the wrong closure. A first-class callable needs no hash (its name does not change on its
own) and carries 0; so does a producer that cannot compute one (a PHP 8.5 polyfill has no engine
access), which then resolves by position.
A stale, unknown or malformed reference throws an Exception on unserialize(), which
cache layers already treat as a miss. References are cache data: they are valid for the code
version and PHP version that produced them, like the metadata they are stored with. For a reference
that must survive across deploys, name the closure: a first-class callable is keyed by its
method name and follows that method across edits.
unserialize() here is a lookup into the fixed set of closures that a class declares about
itself, not a way to build any closure from the code base. A forged payload can at most swap one
declared closure for another: the same kind of risk as an unserialized enum-case name, and much
less than what unserialize() already allows when it rebuilds objects and runs their
__wakeup(). allowed_classes gates it at both ends: without Closure in the
list the payload becomes __PHP_Incomplete_Class, and resolving a reference takes a
closure from its declaring class, which must be listed too.
This limit is the reason first-class callables serialize only when declared in a constant
expression, never from a bare name. If serialize(strlen(...)) worked anywhere, a payload
could name system and become a ready-made way to call any function, against any application
that unserializes untrusted data and then calls the result.
No new reflection API is needed. __serialize() working is the “declared in a constant
expression?” test, and its payload already gives the declaring class, which for a cross-class
first-class callable is neither the scope nor the called class that any current accessor returns. var_export()-based caches (opcache-compiled PHP files) recreate a closure by embedding
the serialize() payload:
return \unserialize('O:7:"Closure":2:{i:0;a:0:{}i:1;a:2:{i:0;s:10:"const-expr";i:1;a:4:{i:0;s:9:"App\\Order";i:1;s:15:"$billingAddress";i:2;i:0;i:3;i:1244831918;}}}');
for anonymous and non-public references, and plain \Foo::bar(...) for public callables, as
Symfony's VarExporter already does (symfony#61657).
Closure gains __serialize()/__unserialize() as regular methods;
var_export() and json_encode() are unchanged.
ext-deepclone extension and a pure-PHP polyfill). The engine computes it when needed instead of storing a table in every compiled class, which would cost memory in opcache; the result is the same either way.No syntax or runtime behavior changes for code that does not serialize closures. Three observable changes:
serialize() now works where it threw before, for closures declared in a class's constant expressions. Code that used the exception to detect closures changes behavior for those; every closure with state or created at runtime still refuses.unserialize() now accepts O:7:“Closure”:... payloads (previously “Unserialization of 'Closure' is not allowed”). Consumers passing allowed_classes are unaffected unless they list Closure.method_exists($closure, '__serialize') now returns true.Next minor version (PHP 8.6).
serialize(), unserialize() and allowed_classes pick the feature up through the regular __serialize()/__unserialize() protocol; references resolve identically with and without opcache and its file cache.serialize() path. External serializers that support __serialize() (igbinary, msgpack) pick it up on their own. This RFC does not cover closures with state (captured variables, bound $this).const V = static function () {...}; requires the site to stay addressable after its initializer is evaluated in place, either by retaining the expression or by having the compiler register the closure in the class. An engine change deserving its own RFC.serialize(strlen(...)) work anywhere. A pure widening the two payload kinds can coexist with, possibly behind an opt-in flag, deliberately left out here.var_export() support: emitting the unserialize(...) / \Foo::bar(...) recreation from var_export() itself. The callable half needs no RFC and can land independently.Voting opens YYYY-MM-DD and closes YYYY-MM-DD. A single vote, 2/3 majority.
Implementation: https://github.com/php/php-src/pull/22716
Tests under Zend/tests/closures/closure_const_expr/ cover: round-trips from every attribute
target and parameter default, nested and runtime-nested declarations; first-class callable sites
(functions, own private/protected methods, inherited self::/parent:: with distinct
bindings, cross-class references, duplicate references); refusals for every row of the table above;
forged and stale payloads (unknown class or site, hash mismatch, resolution without a hash, a key
naming a callable the site never declares such as “system”); allowed_classes gating; shared instances in
an object graph and __wakeup() ordering; the same behavior with opcache (memory and file
cache) and under JIT.
serialize()/unserialize() through the regular __serialize()/__unserialize() protocol, dropping the earlier reflection/reconstruction API (Closure::fromConstExpr(), ReflectionFunction::getConstExprId()/getConstExprClass()): the reference and the declaring class already travel in the payload, so it becomes a reproducible detail of it rather than API. With the id no longer exposed, laid the reference out as four fields [class, site, key, hash] rather than packing the site, position and hash into one “<site>@<key>#<hash>” string. Shortened the document and led with the generic-caching motivation.“<site>@<key>”); first-class callable references keyed by the callable's name (no position, no hash) and normalized to their first declaring element; anonymous references verified by a code hash inside the id (“<site>@<rank>#<hash>”, optional for producers that cannot compute it); tagged-union payload.