Table of Contents

PHP RFC: Serializable closures, by reference to the code that declares them

Introduction

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.

Why in the engine

The extension and polyfill prove the design works and is wanted, but they cannot be where it ends:

Doing it once in the engine replaces this duplicated, partly fragile machinery with a single canonical implementation that the tools above can defer to.

Proposal

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:

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 reference

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.

Security

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.

Reflection and var_export()

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.

Design notes

Backward Incompatible Changes

No syntax or runtime behavior changes for code that does not serialize closures. Three observable changes:

Proposed PHP Version(s)

Next minor version (PHP 8.6).

RFC Impact

Future Scope

Proposed Voting Choices

Voting opens YYYY-MM-DD and closes YYYY-MM-DD. A single vote, 2/3 majority.

Make closures declared in constant expressions serializable as references to their declaration site, as proposed?
Real name Yes No
Final result: 0 0
This poll has been closed.

Patches and Tests

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.

References

Changelog