Skip to content

Reference cross-class and global attribute first-class callables by declaration site on PHP 8.5#27

Merged
nicolas-grekas merged 1 commit into
mainfrom
attribute-closure-provenance
Jun 11, 2026
Merged

Reference cross-class and global attribute first-class callables by declaration site on PHP 8.5#27
nicolas-grekas merged 1 commit into
mainfrom
attribute-closure-provenance

Conversation

@nicolas-grekas

Copy link
Copy Markdown
Member

Stacked on #26.

First-class callables over a method of their own declaring class already serialize as a const-expr declaration-site reference. Cross-class references (#[When(Validators::check(...))]) and global functions (#[When(strlen(...))]) could not: the closure carries no link back to the class whose attribute declares it — its scope is the target, or none at all. PHP 8.6 records that declaring class as engine provenance (ReflectionFunction::getConstExprClass); 8.5 does not, so these fell to the by-name path and required allow_named_closures.

This recovers the declaring class on 8.5 by instrumenting the two paths frameworks use to read attribute metadata — ReflectionAttribute::getArguments() (the closures are the returned argument values) and ::newInstance() (they are properties of the returned attribute instance). For each cross-class or global first-class callable produced, the declaring class, read from the ReflectionAttribute, is recorded in a per-worker index. At to_array time, when the scope-based locate misses, the index yields the declaring class and the closure resolves to the same declaration-site reference (mask 1). Decode is unchanged, and no allow_named_closures opt-in is needed: these remain references resolvable only to what the named class itself declares, and allowed_classes still gates Closure.

  • Persistent, name-keyed index. Keyed and valued by names, not pointers (op_arrays and class entries churn per request without opcache), and kept across requests, so once a declaration has been seen it resolves in later requests too — effective for a worker's lifetime under opcache.preload. The declaring class is re-resolved (without autoloading) and re-located each time, so a stale entry simply misses instead of mis-resolving.
  • Identity-based locate. The walk matches a target by identity: user functions by op_array.opcodes (methods, user globals, anonymous closures), internal functions by name and scope. The capture walk is cycle-guarded, since newInstance() returns an object built by an arbitrary attribute constructor.
  • No INI knob. The hooks are installed at MINIT on a build without native provenance and skipped when ReflectionFunction::getConstExprClass exists, since the engine-id path resolves these directly there. So this self-removes exactly where 8.6-native takes over.

Caveats, in the open:

  • Recovering the declaring class needs reflection's private object layout, mirrored here and tracking the engine structs.
  • Encoding is load/reflection-order dependent: a runtime callable equivalent to a declared one serializes as a site reference once that declaration has been seen this worker, otherwise by name. Always safe (bounded to what classes declare) and decode is deterministic.
  • The lookup does not autoload, so the declaring class must be loaded when serializing (resident under preload).
  • No polyfill counterpart: userland cannot hook reflection. The polyfill decodes these payloads (they are standard declaration-site references), except internal-global-function ones, whose line 0 reference trips its ReflectionFunction::getStartLine()-based staleness check and fails closed (a cache miss, not corruption).

New tests/deepclone_attribute_provenance.phpt covers cross-class via getArguments, own-class unaffected, cross-class via newInstance, internal and user global functions, and the by-name fallback for a callable no attribute declares. It is SKIPIF'd on PHP < 8.5 and on builds that already expose native provenance.

There is no polyfill companion: this capability is extension-only.

Base automatically changed from allow-named-closures to main June 11, 2026 15:29
…eclaration site on PHP 8.5

Building on the allow_named_closures gate: first-class callables over a method
of their own declaring class already serialize as a const-expr declaration-site
reference. Cross-class references (Validators::check(...)) and global functions
(strlen(...)) could not, because the closure carries no link back to the class
whose attribute declares it -- its scope is the target, or none. PHP 8.6 records
that declaring class as engine provenance (ReflectionFunction::getConstExprClass);
8.5 does not.

This recovers it on 8.5 by instrumenting the paths frameworks use to read
attribute metadata: ReflectionAttribute::getArguments() (the closures are the
returned argument values) and ::newInstance() (they are properties of the
returned attribute instance). For each cross-class or global first-class
callable produced, the declaring class -- read from the ReflectionAttribute --
is recorded in a per-worker, name-keyed index. At to_array time, when the
scope-based locate misses, the index yields the declaring class and the closure
resolves to the same declaration-site reference (mask 1); decode is unchanged.
No allow_named_closures opt-in is needed, because these remain declaration-site
references, resolvable only to what the named class itself declares, and
allowed_classes still gates Closure.

The index is keyed and valued by names (not pointers, which churn per request
without opcache) and persists across requests, so once a declaration has been
seen it resolves in later requests too -- effective across a worker's lifetime
under opcache.preload. The locate walk matches a target by identity: user
functions by op_array.opcodes (methods, user globals, anonymous closures),
internal functions by name and scope. The capture walk is cycle-guarded, since
newInstance() returns an object built by an arbitrary attribute constructor.

There is no INI knob: the hooks are installed at MINIT on a build without native
provenance, and skipped when ReflectionFunction::getConstExprClass exists, since
the engine-id path then resolves these directly.

Caveats. Recovering the declaring class needs reflection's private object
layout, mirrored here and tracking the engine structs. Encoding is
load/reflection-order dependent: a runtime callable equivalent to a declared one
serializes as a site reference once that declaration has been seen, otherwise by
name -- always safe (bounded to what classes declare) and decode is
deterministic. The lookup does not autoload, so the declaring class must be
loaded when serializing (resident under preload). There is no polyfill
counterpart: userland cannot hook reflection. The polyfill decodes these
payloads, except internal-global-function ones, whose line-0 reference trips its
ReflectionFunction::getStartLine()-based staleness check and fails closed.
@nicolas-grekas nicolas-grekas force-pushed the attribute-closure-provenance branch from e2ea98d to badf84e Compare June 11, 2026 15:33
nicolas-grekas added a commit to symfony/polyfill that referenced this pull request Jun 11, 2026
…able, and decode its global-function references (nicolas-grekas)

This PR was merged into the 1.x branch.

Discussion
----------

[DeepClone] Suggest the extension when refusing a named callable, and decode its global-function references

| Q             | A
| ------------- | ---
| Branch?       | 1.x
| Bug fix?      | no
| New feature?  | no
| Deprecations? | no
| Issues        | -
| License       | MIT

Two related improvements to how the polyfill cooperates with the extension on first-class callables declared in constant expressions.

**Refusal message.** `deepclone_to_array()` refuses to serialize a closure over a named callable unless `allow_named_closures` is set. The message now warns that the option should only be enabled for trusted input, and points at the extension, which resolves more of these without the opt-in — a first-class callable declared in a **constant expression**, including **cross-class** references (`Validators::check(...)`) and **global functions** (`strlen(...)`), gets its declaring class recovered from reflection and is serialized as a declaration-site reference. Userland has no equivalent reflection hook, and this path only runs when the extension is absent, so the suggestion always applies.

```
deepclone_to_array(): serializing a closure over the named callable "strlen" requires enabling
the "allow_named_closures" option; do it only if you trust the input; alternatively, install the
"deepclone" extension, which can reference callables declared in constant expressions
```

**Decoding the extension's global-function references.** The extension can encode a reference to a global *internal* function declared in an attribute; such a function has no start line, so the reference is stored with line `0`. The polyfill cannot produce these (no reflection hook), but must be able to **decode** them. `ReflectionFunction::getStartLine()` returns `false` for an internal function, so a `line 0` reference was wrongly treated as stale; it is now normalized. With this, an extension-produced reference to a global internal function in an attribute round-trips through the polyfill.

The leading part of the refusal message is unchanged, so the tests (which match a substring) and any callers matching on it are unaffected.

Companion extension implementation: symfony/php-ext-deepclone#27

Commits
-------

bcc4f2b [DeepClone] Suggest the extension when refusing a named callable, and decode its global-function references
@nicolas-grekas nicolas-grekas merged commit badf84e into main Jun 11, 2026
20 checks passed
@nicolas-grekas nicolas-grekas deleted the attribute-closure-provenance branch June 11, 2026 15:56
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.

1 participant