Skip to content

Round-trip closures declared in constant expressions#21

Merged
nicolas-grekas merged 1 commit into
mainfrom
deepclone-constexpr-closures
Jun 10, 2026
Merged

Round-trip closures declared in constant expressions#21
nicolas-grekas merged 1 commit into
mainfrom
deepclone-constexpr-closures

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Jun 10, 2026

Copy link
Copy Markdown
Member

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 both serialize() and deepclone_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], 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 constant expressions are scanned in declaration order for a pointer match. Promoted properties are addressed through their constructor parameter, the canonical surface. Two same-line literals are told apart reliably, where the polyfill has to refuse.
  • Restoring re-evaluates. 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.
  • No code in the payload. 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.

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

@stof

stof commented Jun 10, 2026

Copy link
Copy Markdown
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.
@nicolas-grekas nicolas-grekas force-pushed the deepclone-constexpr-closures branch from c0ccfd0 to d4beb94 Compare June 10, 2026 14:26
@nicolas-grekas nicolas-grekas merged commit d4beb94 into main Jun 10, 2026
15 of 20 checks passed
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
@nicolas-grekas nicolas-grekas deleted the deepclone-constexpr-closures branch June 10, 2026 20:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants