Round-trip closures declared in constant expressions#21
Merged
Conversation
Member
|
The CI is failing. |
PHP 8.5 allows anonymous closures in constant expressions: attribute
arguments, class constants, property and parameter defaults, property
hooks. They are compile-time checked to be static and capture-free, so
they carry no state and are fully described by their declaration site.
deepclone_to_array() now encodes them under a new mask marker (LONG 1)
as [class, site, attrIndex|null, closureIndex, startLine], where site
is "" (the class), "NAME" (constant or enum case), "$name" (property),
"name()" (method), "name()#N" (parameter) or "$name::get()" /
"$name::set()#N" (property hooks).
Identification is exact: a const-expr closure shares its opcodes with
the op_array embedded in the declaring AST, so the scope class's sites
are scanned in declaration order for a pointer match. Promoted
properties are addressed through their constructor parameter, the
canonical surface. deepclone_from_array() re-evaluates the addressed
constant expression, picks the Nth closure from a depth-first walk
(arrays in order, objects through their array-cast properties) and
verifies the declaration line still matches, so payloads that outlived
a code change fail loudly ("stale payload") instead of resolving to a
moved closure.
The payload carries no code, only names and indices; the resolved code
is whatever the loaded class declares. allowed_classes gates both
directions: "Closure" must be allowed before any constant expression is
evaluated on the to_array side, and the payload-named class must be
allow-listed before it is autoloaded on the from_array side, keeping
unserialization free of payload-driven constructor execution.
Closures created at runtime (and any closure the scanner cannot match)
keep throwing NotInstantiableException as before.
c0ccfd0 to
d4beb94
Compare
nicolas-grekas
added a commit
to symfony/polyfill
that referenced
this pull request
Jun 10, 2026
…sions (nicolas-grekas) This PR was merged into the 1.x branch. Discussion ---------- [DeepClone] Support closures declared in constant expressions | Q | A | ------------- | --- | Branch? | 1.x | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | symfony/symfony#63228 | License | MIT PHP 8.5 allows anonymous closures in constant expressions: attribute arguments, class constants, property and parameter defaults, property hooks. Frameworks now meet them when reading attributes (e.g. `#[Assert\When(static function () { ... })]`) and can no longer cache the resulting metadata, since closures refuse `serialize()` and `deepclone_to_array()` alike. Const-expr closures are compile-time checked to be static and capture-free, so they are fully described by their declaration site. `deepclone_to_array()` now encodes them under the new mask marker `1` as `[class, site, attrIndex|null, closureIndex, startLine]`, where `site` is `""` (the class), `"NAME"` (constant or enum case), `"$name"` (property), `"name()"` (method), `"name()#N"` (parameter) or `"$name::get()"` / `"$name::set()#N"` (property hooks). `deepclone_from_array()` re-evaluates the addressed constant expression through reflection, picks the Nth closure of a depth-first walk (arrays in order, objects through their array-cast properties) and verifies the declaration line still matches, so payloads that outlived a code change fail loudly (`stale payload`) instead of resolving to a moved closure. - **Identification.** The extension matches literals by op_array identity; userland cannot, so the polyfill identifies them by closure name, file, line span and signature over a per-class index of every const-expr site. The closure name encodes nesting and method context, and a token-level count of closure literals per source line guards the remaining aliasing case: a closure created at runtime never silently resolves to a const-expr literal, declaration sites the polyfill cannot tell apart are refused. The same literal reached through several surfaces (an attribute argument referencing a class constant, a trait alias, a promoted default applied by a constructor evaluated elsewhere) resolves to the first site that exposes it; promoted properties are addressed through their constructor parameter, the canonical surface. - **Source-less deployments.** The aliasing guard needs to read the declaring file for method- and hook-scoped closures. When the file is not there (deployments shipping only precompiled opcodes), the refusal says so explicitly and points to the extension, which matches literals by op_array identity and needs no sources. - **Security.** The payload carries no code, only names and indices; the resolved code is whatever the loaded class declares. `allowed_classes` gates both directions: `Closure` must be allowed before any constant expression is evaluated on the `to_array` side, and the payload-named class must be allow-listed before it is autoloaded on the `from_array` side, so unserialization cannot drive autoloading or constructor execution for classes outside the allow-list. Payloads are byte-identical and interchangeable with the extension's, covered by tests that run against both implementations (the few cases where op_array identity outperforms the userland heuristic branch per implementation). Closures created at runtime keep throwing `NotInstantiableException`. Companion extension implementation: symfony/php-ext-deepclone#21 Commits ------- e08201b [DeepClone] Support closures declared in constant expressions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PHP 8.5 allows anonymous closures in constant expressions: attribute arguments, class constants, property and parameter defaults, property hooks. Frameworks now meet them when reading attributes (e.g.
#[Assert\When(static function () { ... })], symfony/symfony#63228) and can no longer cache the resulting metadata, since closures refuse bothserialize()anddeepclone_to_array().Const-expr closures are compile-time checked to be static and capture-free, so they are fully described by their declaration site.
deepclone_to_array()now encodes them under a new mask marker (LONG 1) as[class, site, attrIndex|null, closureIndex, startLine], wheresiteis""(the class),"NAME"(constant or enum case),"$name"(property),"name()"(method),"name()#N"(parameter) or"$name::get()"/"$name::set()#N"(property hooks).deepclone_from_array()evaluates the addressed constant expression, picks the Nth closure from a depth-first walk (arrays in order, objects through their array-cast properties) and verifies the declaration line still matches, so payloads that outlived a code change fail loudly (stale payload) instead of resolving to a moved closure.allowed_classesgates both directions:Closuremust be allowed before any constant expression is evaluated on theto_arrayside, and the payload-named class must be allow-listed before it is autoloaded on thefrom_arrayside, so unserialization cannot drive autoloading or constructor execution for classes outside the allow-list.Closures created at runtime (and any closure the scanner cannot match) keep throwing
NotInstantiableException. New.phpts cover every site kind (hooks, traits including aliasing, enums, promoted constructor properties, several closures per attribute, factory constants whose runtime product is refused), payload validation, allow-list gating, staleness, and a JSON round trip of the payload. The polyfill test suite also passes against this extension, including the payload-interchange cases.Companion polyfill implementation: symfony/polyfill#630