====== PHP RFC: Serializable closures, by reference to the code that declares them ====== * Version: 0.1 * Date: 2026-06-10 * Author: Nicolas Grekas * Status: Draft * First Published at: https://wiki.php.net/rfc/serializable_closures ===== Introduction ===== Closures have never been serializable in PHP, for good structural reasons: a closure may capture variables by value or by reference, may be bound to an object, and may be rebound at runtime. A serialization payload for such a closure would have to embed code and captured state, which is both semantically murky and a deserialization attack surface. PHP 8.5 created a class of closures for which none of those reasons apply. The [[https://wiki.php.net/rfc/closures_in_const_expr|Closures in constant expressions]] and [[https://wiki.php.net/rfc/fcc_in_const_expr|First-class callables in constant expressions]] RFCs allow closures in attribute arguments and other constant expressions, with hard restrictions: anonymous closures must be ''static'' and cannot ''use()'' any variable, and first-class callables can only reference named functions and static methods. Such a closure carries **no state at all**. It is fully described by //where it is declared//, the same way an enum case is fully described by its name. This RFC makes these closures serializable, by storing a **reference to their declaration site** rather than the code itself. This covers both forms a closure can take in a constant expression: * an anonymous closure declaration: ''static function () { ... }''; * a first-class callable reference: ''strlen(%%...%%)'', ''self::isStrict(%%...%%)'', ''Validators::check(%%...%%)''. ''unserialize()'' resolves the reference against the loaded code base and recreates an equivalent closure, as if the declaring constant expression had just been evaluated again. No code ever travels in the payload, and a payload cannot reference anything that the reader's own code base does not already declare. Closures that carry state (captured variables, bound ''$this'') and closures created at runtime (anonymous functions and first-class callables in function bodies) remain non-serializable, with the exact same error as today. ===== Problem Statement ===== ==== Attributes now produce closures, and closures poison caches ==== Attributes are PHP's declarative metadata system, and the ecosystem caches derived metadata aggressively: validator metadata, serializer metadata, DI container definitions, routing tables. These caches are built once and stored through ''serialize()'' (PSR-6/PSR-16 pools) or through ''var_export()'' (opcache-friendly PHP files). Since PHP 8.5, attributes can carry closures, and this is not a corner case: it was the headline use case of the 8.5 RFCs. The Symfony validator, for instance, accepts exactly this: class Order { #[Assert\When(static function () { return self::$strictMode; }, constraints: [new Assert\NotBlank()])] public string $billingAddress; } The metadata derived from this attribute contains the closure. The cache layer calls ''serialize()'' on it, which throws, the framework catches the error, and the metadata is silently recomputed **on every request**. Nothing fails loudly; the application just gets slower. This is [[https://github.com/symfony/symfony/issues/63228|symfony#63228]], reported shortly after the PHP 8.5 release, and the same trap exists for every metadata cache in the ecosystem. The language added a feature whose natural habitat is cached metadata, while its values are hostile to caching. This RFC closes that gap. ==== Userland cannot fix this well ==== Userland has two known workarounds, both with significant drawbacks: * **Source extraction** ([[https://github.com/opis/closure|opis/closure]], [[https://github.com/laravel/serializable-closure|laravel/serializable-closure]]): re-read the file, slice the closure's source text, store it in the payload, ''eval()'' it on unserialize. This puts executable code in payloads (an injection surface that both libraries mitigate with signing), drifts from the compiled code when files change, and breaks ''%%__FILE__%%''/''%%__LINE__%%''/''self'' semantics unless carefully patched. * **Re-evaluation wrappers**: frameworks can wrap each closure in an invokable object that records "attribute X, argument Y of class Z" and re-runs reflection on first use. This works (Symfony can ship it for 8.5) but every framework must build and maintain its own referencing system for what is fundamentally one engine-level concept: //this closure is the one declared right there//. The engine is the only layer that can give such references first-class, code-free, validated semantics, uniformly for the whole ecosystem. ==== Named callables have the same problem with serialize() ==== ''#[Assert\When(Foo::isStrict(%%...%%))]'' has the same caching behavior as the inline closure: ''serialize()'' refuses fake closures too. The ''var_export()'' side of this is already solved in userland (Symfony's VarExporter exports named closures as ''\Foo::isStrict(%%...%%)'', [[https://github.com/symfony/symfony/pull/61657|symfony#61657]]), which makes the asymmetry worse: the recommended, "cache-friendly" way of writing callable attributes still breaks every ''serialize()''-based cache. ===== Proposal ===== Closures become serializable when, and only when, they are declared by a class's constant expressions, in attribute arguments and parameter default values (class constant values and property defaults are excluded for now: see the Rationale). Two forms of declaration exist. ==== 1. Anonymous closures ==== A closure declared in a constant expression attached to a class member becomes serializable: * in attribute arguments, of the class itself, of its constants, properties, property hooks, methods and parameters; * in parameter default values of its methods; * including when nested in further closures declared there. The payload is a reference to the declaration site, made of the **declaring class name**, a **stable id**, and the **start line** of the closure (used as a staleness check): $closure = (new ReflectionProperty(Order::class, 'billingAddress')) ->getAttributes()[0]->getArguments()[0]; $payload = serialize($closure); // O:7:"Closure":3:{s:5:"class";s:5:"Order";s:2:"id";i:0;s:4:"line";i:3;} $again = unserialize($payload); $again(); // behaves exactly like $closure() 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), no bound ''$this'', and fresh ''static'' variables. It is a new Closure instance, the same way two calls to ReflectionAttribute::getArguments() return two instances today. **The id is deterministic.** It is the closure's rank among all closure-declaring constant expressions of the class (anonymous declarations and first-class callable references alike), counted in a fixed declaration-order traversal of the class (class attributes first, then constants, properties and hooks, then methods with their parameter lists). It is derived from the compiled class alone, never from runtime state, evaluation order or caches, which is where its stability comes from: every process running the same source computes the same rank, with or without opcache. The flip side is that editing the class may renumber the ranks. References are therefore validated when they resolve (see below) and must be treated like the cache artifacts they are embedded in: valid for the code revision that produced them, and per PHP version. Userland should obtain ids from the engine (see the Reflection API below) rather than compute them. When the class's source changes, a stored reference either stops resolving or is rejected by the line check; both throw an Exception on ''unserialize()'', which cache layers already treat as a miss. ==== 2. First-class callable references ==== A first-class callable in a constant expression is a closure declaration site like any other: the class's source declares, at a fixed position, "a closure over this callable". Closures created by evaluating such a reference serialize with the **same payload** as anonymous declarations: the declaring class, the id, the start line. The engine tracks this provenance when it evaluates the constant expression; an identical-looking closure created at runtime does not have it and refuses to serialize. class Order { #[Assert\When(self::isStrict(...), constraints: [new Assert\NotBlank()])] public string $billingAddress; private static function isStrict(): bool { ... } } $closure = /* from ReflectionAttribute::getArguments() */; serialize($closure); // O:7:"Closure":3:{s:5:"class";s:5:"Order";s:2:"id";i:0;s:4:"line";i:3;} Resolution re-evaluates the declared reference in the scope of the declaring class, exactly like attribute evaluation does. Two important properties follow: * **Any visibility works**, because the site's own access rules are simply re-run: a private or protected helper referenced from an attribute of its own class (as above, an idiomatic pattern since validation helpers have no reason to be public) round-trips fine, while a forged payload gains nothing: it can only designate references that the class legitimately declares. * **Late static binding is preserved**: ''self::'', ''parent::'' and explicit class names resolve exactly as they did when the attribute was evaluated, since it is the same evaluation. This works uniformly for functions (''strlen(%%...%%)''), own methods (''self::isStrict(%%...%%)''), and cross-class references (''Validators::check(%%...%%)''), in attribute arguments and parameter default values alike. ==== What stays non-serializable ==== Everything below keeps today's behavior, including the exact error (Exception: ''Serialization of 'Closure' is not allowed''): ^ Closure kind ^ Why ^ | Anonymous functions declared in function bodies | No declaration site; may capture variables and ''$this'' | | Arrow functions | Capture by value implicitly | | Closures over named callables created at runtime (''strlen(%%...%%)'' / ''Foo::bar(%%...%%)'' in function bodies, Closure::fromCallable(), ReflectionMethod::getClosure()) | No declaration site; serializing them by name would make ''unserialize()'' a closure factory over the whole code base instead of over what classes declare | | Closures bound to an object (''$obj->method(%%...%%)'', ''Closure::bind()'' with ''$this'') | Carry object state | | Closures created from ''%%__call()%%''/''%%__callStatic()%%'' trampolines | No real backing method; the engine already rejects them in constant expressions anyway | | Const-expr closures in **class constant values** and **property default values** | See Future Scope | | Const-expr closures in attributes of free functions, or of anonymous classes | No autoloadable / stable container name | | Const-expr closures rebound to a different scope | No longer the declared value | Note that the boundary is **declaration, not syntax**: the same expression, anonymous closure or first-class callable, is serializable when it appears in a constant expression and not when it appears in a method body, because only the former is a declaration the class makes about itself, with an identity that survives the process. ==== Security model ==== The payload contains **a class name and two integers, never code**. Resolution can only ever produce a closure that the named class's own source declares in one of its constant expressions: there is nothing else a payload can express. ''unserialize()'' is not a closure factory over the code base; it is a lookup into the fixed, finite set of closures that classes declare about themselves. A forged payload can therefore at worst point at a //different// declared closure, the same class of risk as unserializing an enum case name or a class name today, and far less than what ''unserialize()'' already allows through ''%%__wakeup()%%'' gadgets. Visibility needs no special rule: resolving a first-class callable site re-runs the same accessibility checks, in the same scope, that attribute evaluation runs, so a reference resolves for the reader exactly when the declaration is legal for the declarer. And the existing ''allowed_classes'' hardening applies unchanged: with ''unserialize($data, ['allowed_classes' => [...]])'' not listing Closure, these payloads produce __PHP_Incomplete_Class like any other non-listed object, and no resolution happens at all. All malformed, unresolvable or stale payloads throw an Exception with a descriptive message, for example: Invalid serialization data for Closure object (constant-expression closure 3 of class Order not found) Invalid serialization data for Closure object (cannot load class "Order") ==== Reflection and exporter support ==== ''var_export()''-based caches (PHP files compiled by opcache) need to //generate code// that recreates the closure, rather than a binary payload. Three additions support this: * ReflectionFunction::getConstExprId(): ?int returns the declaration-site id of a closure declared in a constant expression, or ''null'' for any other closure. * ReflectionFunction::getConstExprClass(): ?string returns the declaring class. This is a distinct accessor because for first-class callable references no existing one carries it: a ''Validators::check(%%...%%)'' reference declared by ''Order'' has getClosureScopeClass() and getClosureCalledClass() both pointing at ''Validators'', a ''parent::'' reference has both pointing at the parent class, and a function reference (''strlen(%%...%%)'') has neither, while all of them are declared, and serialized, by ''Order''. The existing accessors describe the closure's runtime semantics (visibility scope, ''static::'' binding); getConstExprClass() describes where it was declared. * Closure::fromConstExpr(string $class, int $id): Closure recreates the closure from such a reference. It throws an Error if the class cannot be loaded and a ValueError if the id does not resolve. An exporter then emits self-contained, opcache-friendly code: // generated cache file return \Closure::fromConstExpr(\App\Order::class, 0); For closures over **public** named callables, exporters can keep emitting plain first-class callable syntax (''\Order::isStrict(%%...%%)''), as Symfony's VarExporter already does: in generated code the expression itself is the reference. fromConstExpr() is what makes the remaining cases exportable, anonymous closures and non-public references, since generated code runs in global scope and could not name a private helper directly. The identity of any named closure (its name, scope, called scope, staticness) remains introspectable through existing reflection regardless of visibility; reflection describes closures already in hand and is deliberately not restricted. ==== Behavior details ==== * **References track live code**: like a ''\Foo::bar(%%...%%)'' expression in a generated cache file resolving to the current implementation of the method, a declaration-site reference resolves to the closure as **currently** declared at that site. Payloads do not pin a snapshot of the body; deploying a fixed closure body fixes already-cached references to it. * **Fresh instances**: unserialization creates a new Closure object. Within one ''unserialize()'' call, shared references in the payload graph are preserved as usual (the same closure serialized twice in one graph unserializes as one instance). * **''static'' variables start fresh**: a reference designates the closure //as declared//, not a snapshot of its runtime state. This matches re-evaluating the attribute, and matches what the 8.5 RFCs specify for repeated evaluation. * **No change to other serialization surfaces**: ''var_export()'' and ''json_encode()'' behave exactly as before; this RFC only touches ''serialize()''/''unserialize()''. * Closure now declares __serialize()/__unserialize() as regular (engine-provided) methods; they are visible through reflection like any other method. ===== Rationale ===== ==== Why references instead of embedding source code ==== The Opis-style alternative (store the closure's source text, compile it on unserialize) was rejected deliberately: * payloads would contain executable code, turning every cache store into an injection surface that needs signing to be safe; * the embedded source can drift from the deployed code (the cache says one thing, the file says another), whereas a reference always resolves against the **single source of truth**, the loaded class; * ''%%__FILE__%%'', ''%%__LINE__%%'', ''self'', private-member access and ''static::'' all need careful patching when code is re-compiled out of context; a reference recreates the closure in its original context, so all of these are correct by construction. The 8.5 restrictions are precisely what make the reference design possible: since the closure can capture nothing, the reference loses nothing. ==== Why first-class callables serialize only when declared in constant expressions ==== An earlier draft of this proposal serialized //every// closure over a named function or public static method by name, so that ''serialize(strlen(%%...%%))'' worked anywhere. That design was dropped for three reasons: * **Needless surface.** The motivating problem is cached metadata derived from constant expressions. Making every named closure serializable turns ''unserialize()'' into a closure factory over every function and public static method of the code base, a much larger capability than the problem requires. With declaration-site references only, a payload can designate nothing beyond the finite set of closures that classes declare about themselves. * **A visibility dilemma it cannot solve.** Name-based payloads cross trust domains, so they must be restricted to what unscoped code could create: public static methods. But attributes legitimately reference //private// helpers of their own class (''#[When(self::isStrict(%%...%%))]'', where there is no reason to make the helper public), and those would have stayed unserializable, re-creating the very cache trap this RFC exists to fix. Site references dissolve the dilemma: resolution re-evaluates the declaration in its own scope, so visibility is enforced by the same rule that allowed the attribute to compile and evaluate in the first place. * **One principle, one payload.** Anonymous declarations and callable references get the same format, the same staleness contract, and the same security analysis, instead of two payload kinds with different rules. The engine tracks the necessary provenance at no observable cost: closures created while evaluating a constant expression are marked as such, with the declaring class recorded. A value-identical first-class callable created in a function body does not carry the mark and refuses to serialize. This asymmetry is the same one anonymous closures already have (the same body in a method body refuses too), and it is the point: serializability is a property of the //declaration//, not of the value. Extending name-based serialization to runtime-created closures over public callables would remain possible later, as a pure widening (see Future Scope); this RFC deliberately does not include it. ==== Why class constant values and property defaults are excluded for now ==== ''const FOO = static function () {...};'' and ''public $cb = Validators::check(%%...%%);'' are constant expressions too, and conceptually they should qualify; both forms of declaration are affected equally. They are excluded because the engine does not reliably retain these initializer expressions once they have been evaluated: depending on configuration, they are evaluated //in place// and freed. A declaration site that may or may not still exist cannot participate in the id numbering without breaking the contract that references resolve identically in every process and configuration. Making these sites addressable requires the engine to retain them, or to register their closures at compile time (the stored-index alternative discussed above), an engine refactoring with its own trade-offs that is deliberately left as future scope rather than blocking the attribute use case motivating this proposal. Until then, these closures keep failing to serialize exactly as they do today: nothing regresses and the door stays open. ==== Why this id, and not another addressing scheme ==== Two natural alternatives were considered for the id. **A compiler-assigned index stored in the class.** Instead of deriving the rank when a reference is created or resolved, the compiler would number each constant-expression closure while compiling the class and store the table in the compiled artifact. The observable contract would be identical: a stored index is renumbered by source edits in exactly the same situations as the derived rank, so it is neither more nor less stable, and the staleness tripwire is needed either way. The difference is economics: a stored table costs memory in every compiled class (including in opcache shared memory) and new persistence plumbing, while deriving the rank costs a class traversal per closure serialized or resolved, negligible next to unserialization itself. The proposal therefore specifies the contract (deterministic per source revision and PHP version) and derives the id; switching to a stored index later would be invisible to userland. A stored index does have one distinctive power, noted under Future Scope: by keeping the compiled closures of class constant values and property defaults reachable after their initializers are evaluated, it could lift the in-place-evaluation exclusion without changing how those initializers are evaluated. **The rank among attribute arguments of any type, not only closures.** Numbering every argument slot looks simpler but addresses the wrong unit. One argument may declare several closures (an array of callbacks is a single argument), so a within-argument ordinal is still needed; parameter default values are not attribute arguments, so a second numbering domain appears; and the id becomes //less// stable, since adding or removing any scalar argument before the closure renumbers it, while the closure-only rank is invariant to every edit that does not add, remove or reorder closures themselves. Counting only closures numbers exactly the things being referenced, with the smallest possible sensitivity to unrelated edits. A fully symbolic variant ("argument ''callback'' of the second attribute of property ''$x''") reads better in payloads but combines the drawbacks: paths must reach into nested arrays and chained closures, attribute and parameter-default sites need different address shapes, an ordinal is still required when one argument declares several closures, and the robustness it buys (references surviving edits to //other// members of the class) is not actually desirable for cache artifacts, where failing closed on any change to the file is the expected behavior. The flat closure rank plus the line tripwire provides the same safety with a two-integer payload. ==== Why the line number, and why not a stronger fingerprint ==== Because the id is positional, an edit to the class can make a stored id designate a //different declaration site// than the one that was serialized: removing an attribute renumbers every closure after it. That is the one failure that must not be silent. Storing the closure's start line makes essentially every renumbering edit fail loudly: for a stale id to resolve silently, the site that now occupies it would have to sit on the very line recorded in the payload. ''unserialize()'' otherwise throws an exception that cache layers experience as a regular miss. The line number has three properties that make it the right tripwire: it is already recorded in the compiled class for every declaration site (functions know their start line, and so do the nodes of constant expressions), resolution never touches the filesystem, and it means the same thing in every PHP build. Stronger checks were considered, and each pins the wrong thing: * **A checksum of the compiled bytecode** is not a stable identity: the same source compiles differently with and without opcache's optimizer, across optimization levels, and across PHP versions. Payloads written by one pipeline would be unreadable by another while referencing perfectly identical source code. * **A hash of the source text** would require re-reading the file, at serialization time and again at resolution time. The engine deliberately never goes back to source files at runtime: the file may be newer than the compiled code that opcache is serving (the exact drift scenario the check is supposed to detect would corrupt the check itself), may be unreachable (phars, streams, ''open_basedir''), or may not exist at all (''eval()''-defined classes). The compiled class in memory is the single source of truth, and the check must come from it. * **A compile-time fingerprint of the closure's body**, computed once by the compiler and stored in the class, would be technically sound, but it guards against the wrong event. These payloads are //references//, not snapshots: nobody expects a ''\Foo::bar(%%...%%)'' expression in a generated cache file to pin the implementation of the method it names, and a declaration-site reference equally resolves to whatever the site declares **now**. Deploying a bug fix to a closure's body is supposed to be picked up by already-cached references. A body fingerprint would instead turn every legitimate body edit into a resolution failure, and would permanently grow every class using the feature to enforce snapshot semantics that references deliberately do not have. In other words: identity of the //site// is what the id encodes and what the line check defends; identity of the //body// is intentionally not part of the contract. The residual blind spot is an edit that renumbers sites while keeping the resolved closure's start line unchanged, e.g. reordering two closures declared on the same line. This falls under the discipline serialized payloads already require today (a payload is only valid for the code revision that produced it), and frameworks invalidate their metadata caches on file changes anyway. ==== Naming ==== ''fromConstExpr'' / ''getConstExprId'' / ''getConstExprClass'' follow the "constant expression" terminology established by the 8.5 RFCs. ''fromConstantExpression'' (spelled out) and ''fromDeclarationSite'' are plausible alternatives; the author has no strong attachment and will follow list feedback. ===== Backward Incompatible Changes ===== No syntax or runtime behavior changes for code that does not serialize closures. Three observable changes: * ''serialize()'' now **succeeds** where it previously threw, for closures declared in constant expressions of a class. Code using the exception as a closure detector (a known anti-pattern) would change behavior for those; refusal is preserved for every stateful or runtime-created closure, so the common defensive case is unaffected. * ''unserialize()'' now accepts ''O:7:"Closure":...'' payloads (it previously failed with "Unserialization of 'Closure' is not allowed"). Consumers using ''allowed_classes'' are unaffected unless they list Closure. * method_exists($closure, '__serialize') now returns ''true''. ===== Proposed PHP Version(s) ===== Next minor version (PHP 8.6). ===== RFC Impact ===== ==== To SAPIs ==== None. ==== To Existing Extensions ==== * ''opcache'': no changes required; references resolve identically with and without opcache, including with the file cache. * ''reflection'': adds ReflectionFunction::getConstExprId() and ReflectionFunction::getConstExprClass(). * ''standard'': no changes required; ''serialize()'', ''unserialize()'' and its ''allowed_classes'' filter pick the feature up through the regular %%__serialize()%%/%%__unserialize()%% protocol. ''var_export()'' is unchanged. * ''session'': no changes required; declared closures stored in ''$_SESSION'' now serialize instead of failing the session write. * Other bundled extensions are unaffected. External serializers (igbinary, msgpack) that honor %%__serialize()%% pick the feature up automatically; those that special-case Closure keep their current behavior until updated. ==== To Ecosystem ==== * Metadata caches (Symfony validator/serializer, Doctrine, API Platform, PSR-6 marshallers) start working with closure-carrying attributes with **no code changes** on the ''serialize()'' path. * Exporters (Symfony VarExporter and similar) gain a one-line strategy for anonymous and non-public attribute closures via getConstExprClass() / getConstExprId() / fromConstExpr(), complementing their existing first-class callable emission for public ones. * Static analyzers and IDEs need to learn the three new methods. * opis/closure and laravel/serializable-closure remain relevant for what this RFC deliberately does not cover: stateful closures. ===== Future Scope ===== * **Class constant values and property defaults**: letting ''const VALIDATOR = static function () {...};'' serialize under the same model requires the declaration site to remain addressable after the initializer is evaluated in place, either by preserving these constant expressions past first evaluation, or by having the compiler register such closures in the class (the stored-index alternative discussed in the Rationale). Both are engine refactorings with their own trade-offs, deserving their own RFC. * **Global constants and free-function attributes**: addressable in principle (by constant/function name), but functions and constants are not autoloadable, which weakens the resolution story. Could be added incrementally. * **Name-based serialization of runtime-created named closures**: making ''serialize(strlen(%%...%%))'' work anywhere by serializing closures over public named callables as their name. This is a pure widening of this proposal, discussed and deliberately left out in the Rationale; the two payload kinds can coexist if a need emerges. * **Native ''var_export()'' support**: emitting ''\Closure::fromConstExpr(...)'' / ''\Foo::bar(%%...%%)'' from ''var_export()'' itself, instead of leaving it to userland exporters. ===== Proposed Voting Choices ===== Voting opens YYYY-MM-DD and closes YYYY-MM-DD. A single vote on the whole proposal, requiring a 2/3 majority. * Yes * No ===== Patches and Tests ===== Implementation: https://github.com/nicolas-grekas/php-src/pull/4 Tests live under ''Zend/tests/closures/closure_const_expr/'' and cover: * Round-trips from every attribute target (class, constant, enum case, property, hook, method, parameter), parameter defaults, nested and runtime-nested declarations * First-class callable sites: functions (incl. namespaced), own private/protected methods, inherited methods via ''self::'' and ''parent::'' (with their distinct ''static::'' bindings), cross-class references * Runtime-created named closures keep refusing, even when an identical reference exists in an attribute * Inheritance and traits * Scope restoration: ''self::'', private member access * Refusals: every row of the "stays non-serializable" table, with unchanged error message * Forged and stale payloads: unknown class/id, line mismatch, type confusion, name-shaped payloads, double initialization * ''allowed_classes'' gating (''%%__PHP_Incomplete_Class%%'', no resolution) * Object-graph behavior: shared instances, ''%%__wakeup()%%'' ordering * Identical behavior with opcache (shared memory and file cache) and under JIT ===== References ===== * [[https://wiki.php.net/rfc/closures_in_const_expr|RFC: Closures in constant expressions]] (PHP 8.5) * [[https://wiki.php.net/rfc/fcc_in_const_expr|RFC: First-class callables in constant expressions]] (PHP 8.5) * [[https://github.com/symfony/symfony/issues/63228|symfony#63228: Static callable in attributes may have performance issues]] * [[https://github.com/symfony/symfony/pull/61657|symfony#61657: VarExporter support for named closures]] * [[https://github.com/opis/closure|opis/closure]], [[https://github.com/laravel/serializable-closure|laravel/serializable-closure]]: userland source-extraction serializers