Reference cross-class and global attribute first-class callables by declaration site on PHP 8.5#27
Merged
Conversation
…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.
e2ea98d to
badf84e
Compare
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
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.
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 requiredallow_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 theReflectionAttribute, is recorded in a per-worker index. Atto_arraytime, 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 noallow_named_closuresopt-in is needed: these remain references resolvable only to what the named class itself declares, andallowed_classesstill gatesClosure.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.op_array.opcodes(methods, user globals, anonymous closures), internal functions by name and scope. The capture walk is cycle-guarded, sincenewInstance()returns an object built by an arbitrary attribute constructor.ReflectionFunction::getConstExprClassexists, since the engine-id path resolves these directly there. So this self-removes exactly where 8.6-native takes over.Caveats, in the open:
line 0reference trips itsReflectionFunction::getStartLine()-based staleness check and fails closed (a cache miss, not corruption).New
tests/deepclone_attribute_provenance.phptcovers cross-class viagetArguments, own-class unaffected, cross-class vianewInstance, internal and user global functions, and the by-name fallback for a callable no attribute declares. It isSKIPIF'd on PHP < 8.5 and on builds that already expose native provenance.There is no polyfill companion: this capability is extension-only.