Drop scoped mode — mangled-only is now the only shape (v0.5.0)#15
Merged
nicolas-grekas merged 1 commit intomainfrom Apr 16, 2026
Merged
Drop scoped mode — mangled-only is now the only shape (v0.5.0)#15nicolas-grekas merged 1 commit intomainfrom
nicolas-grekas merged 1 commit intomainfrom
Conversation
nicolas-grekas
added a commit
to symfony/polyfill
that referenced
this pull request
Apr 15, 2026
Mirrors symfony/php-ext-deepclone#15. The scoped shape `[$className => ['prop' => $val]]` and the flat mangled shape `['prop' => $val, "\0Class\0priv" => $val2]` were functionally equivalent; keeping both required a fast path, an intermediate scoped_vars grouping for the MANGLED_VARS branch, a double-pass write, and a footgun guard. `deepclone_hydrate()` now interprets `$vars` exclusively as the flat `(array) $obj`-shape. The parser groups keys by write scope in one pass and dispatches to the per-scope cached hydrator — same perf as before on the flat-input path (which was already the fast path), simpler code. ### BC breaks - Scoped-shape input `[$class => ['prop' => $val]]` is no longer recognized. Callers migrate by flattening: bare names for public / protected / most-derived-private, `"\0ParentClass\0prop"` for parent-declared private. - `DEEPCLONE_HYDRATE_MANGLED_VARS` constant removed (the mode is now implicit). Drop it from `$flags` arguments. - `DEEPCLONE_HYDRATE_PRESERVE_REFS` moves from `1 << 3` to `1 << 2` (filling the slot vacated by MANGLED_VARS). Symbolic references via the constant name are unaffected. - The "NUL-prefixed scope key → footgun ValueError" guard is gone — NUL-prefix keys are now valid mangled keys. - Error message for non-parent mangled-key class changed from `scope "X" is not a parent` to `key scope "X" is not a parent`. ### Behavior changes affecting callers - Bare names no longer auto-resolve to parent-private slots via Reflection. Parent-private requires explicit `"\0ParentClass\0prop"`. Matches the ext's behavior. ### Tests Six scoped-mode-only test cases removed (integer-key / non-array / interface / unrelated / non-existing scope); the remaining `*MatchesUnserialize` tests cover the equivalent behavior in the flat shape. All 360 DeepClone tests pass locally. `symfony/var-exporter`'s `Hydrator` / `Instantiator` still need a separate follow-up to keep their `$scopedVars` / `$mangledVars` BC by translating to the flat shape internally.
2 tasks
nicolas-grekas
added a commit
to symfony/polyfill
that referenced
this pull request
Apr 16, 2026
Mirrors symfony/php-ext-deepclone#15. The scoped shape `[$className => ['prop' => $val]]` and the flat mangled shape `['prop' => $val, "\0Class\0priv" => $val2]` were functionally equivalent; keeping both required a fast path, an intermediate scoped_vars grouping for the MANGLED_VARS branch, a double-pass write, and a footgun guard. `deepclone_hydrate()` now interprets `$vars` exclusively as the flat `(array) $obj`-shape. The parser groups keys by write scope in one pass via a cached per-class `propertyScopes` index (adapted from `LazyObjectRegistry::getPropertyScopes()` in `symfony/var-exporter`) that resolves every mangled-key shape — bare `"foo"`, `"\0*\0foo"`, `"\0Class\0foo"` — with a single hash lookup and correctly points bare names at the most-derived declaring class for shadowed parent-privates. A scan-based fast path hands `$vars` straight to the `$class` hydrator when every key resolves to `$class` (the common flat-DTO shape), bypassing the intermediate grouping array. ### BC breaks - Scoped-shape input `[$class => ['prop' => $val]]` is no longer recognized. Callers migrate by flattening: bare names for public / protected / most-derived-private, `"\0ParentClass\0prop"` for parent-declared private. - `DEEPCLONE_HYDRATE_MANGLED_VARS` constant removed (the mode is now implicit). Drop it from `$flags` arguments. - `DEEPCLONE_HYDRATE_PRESERVE_REFS` moves from `1 << 3` to `1 << 2` (filling the slot vacated by `MANGLED_VARS`). Symbolic references via the constant name are unaffected. - The "NUL-prefixed scope key → footgun ValueError" guard is gone — NUL-prefix keys are now valid mangled keys. - A NUL-prefixed key that does not resolve to a declared property on `$class` or a parent raises `ValueError("invalid mangled key")` instead of silently creating a dynamic property. - Bare names no longer auto-resolve to parent-private slots via Reflection alone; the `propertyScopes` index handles that case automatically, and matches the ext's behavior (parent-private is reached when the bare name is declared on exactly one ancestor). ### Perf (14-prop DTO, PHP 8.4, warm caches) | shape | before | after | vs Reflection | |----------------------------|-----------:|----------:|--------------:| | flat class | 3,500 ns | 1,930 ns | 1.10× | | 3-level inheritance | 4,300 ns | 3,760 ns | 2.27× | ### Tests - Six scoped-mode-only test cases removed (integer-key / non-array / interface / unrelated / non-existing scope) — the remaining `*MatchesUnserialize` tests cover the equivalent behavior in the flat shape. - Added `testHydrateBareNameReachesParentPrivate` covering the bare-name-to-parent-private resolution via the `propertyScopes` index. - All 362 DeepClone tests pass. A separate follow-up is needed on `symfony/var-exporter` to preserve `Hydrator::hydrate()` / `Instantiator::instantiate()`'s existing `$scopedVars` / `$mangledVars` parameter BC by translating to the flat shape internally.
e9988ef to
b838c3e
Compare
nicolas-grekas
added a commit
to symfony/polyfill
that referenced
this pull request
Apr 16, 2026
Mirrors symfony/php-ext-deepclone#15. The scoped shape `[$className => ['prop' => $val]]` and the flat mangled shape `['prop' => $val, "\0Class\0priv" => $val2]` were functionally equivalent; keeping both required a fast path, an intermediate scoped_vars grouping for the MANGLED_VARS branch, a double-pass write, and a footgun guard. `deepclone_hydrate()` now interprets `$vars` exclusively as the flat `(array) $obj`-shape. The parser groups keys by write scope in one pass via a cached per-class `propertyScopes` index (adapted from `LazyObjectRegistry::getPropertyScopes()` in `symfony/var-exporter`) that resolves every mangled-key shape — bare `"foo"`, `"\0*\0foo"`, `"\0Class\0foo"` — with a single hash lookup and correctly points bare names at the most-derived declaring class for shadowed parent-privates. A scan-based fast path hands `$vars` straight to the `$class` hydrator when every key resolves to `$class` (the common flat-DTO shape), bypassing the intermediate grouping array. ### BC breaks - Scoped-shape input `[$class => ['prop' => $val]]` is no longer recognized. Callers migrate by flattening: bare names for public / protected / most-derived-private, `"\0ParentClass\0prop"` for parent-declared private. - `DEEPCLONE_HYDRATE_MANGLED_VARS` constant removed (the mode is now implicit). Drop it from `$flags` arguments. - `DEEPCLONE_HYDRATE_PRESERVE_REFS` moves from `1 << 3` to `1 << 2` (filling the slot vacated by `MANGLED_VARS`). Symbolic references via the constant name are unaffected. - The "NUL-prefixed scope key → footgun ValueError" guard is gone — NUL-prefix keys are now valid mangled keys. - A NUL-prefixed key that does not resolve to a declared property on `$class` or a parent raises `ValueError("invalid mangled key")` instead of silently creating a dynamic property. - Bare names no longer auto-resolve to parent-private slots via Reflection alone; the `propertyScopes` index handles that case automatically, and matches the ext's behavior (parent-private is reached when the bare name is declared on exactly one ancestor). ### Perf (14-prop DTO, PHP 8.4, warm caches) | shape | before | after | vs Reflection | |----------------------------|-----------:|----------:|--------------:| | flat class | 3,500 ns | 1,930 ns | 1.10× | | 3-level inheritance | 4,300 ns | 3,760 ns | 2.27× | ### Tests - Six scoped-mode-only test cases removed (integer-key / non-array / interface / unrelated / non-existing scope) — the remaining `*MatchesUnserialize` tests cover the equivalent behavior in the flat shape. - Added `testHydrateBareNameReachesParentPrivate` covering the bare-name-to-parent-private resolution via the `propertyScopes` index. - All 362 DeepClone tests pass. A separate follow-up is needed on `symfony/var-exporter` to preserve `Hydrator::hydrate()` / `Instantiator::instantiate()`'s existing `$scopedVars` / `$mangledVars` parameter BC by translating to the flat shape internally.
nicolas-grekas
added a commit
to symfony/polyfill
that referenced
this pull request
Apr 16, 2026
Mirrors symfony/php-ext-deepclone#15. The scoped shape `[$className => ['prop' => $val]]` and the flat mangled shape `['prop' => $val, "\0Class\0priv" => $val2]` were functionally equivalent; keeping both required a fast path, an intermediate scoped_vars grouping for the MANGLED_VARS branch, a double-pass write, and a footgun guard. `deepclone_hydrate()` now interprets `$vars` exclusively as the flat `(array) $obj`-shape. The parser groups keys by write scope in one pass via a cached per-class `propertyScopes` index (adapted from `LazyObjectRegistry::getPropertyScopes()` in `symfony/var-exporter`) that resolves every mangled-key shape — bare `"foo"`, `"\0*\0foo"`, `"\0Class\0foo"` — with a single hash lookup and correctly points bare names at the most-derived declaring class for shadowed parent-privates. A scan-based fast path hands `$vars` straight to the `$class` hydrator when every key resolves to `$class` (the common flat-DTO shape), bypassing the intermediate grouping array. ### BC breaks - Scoped-shape input `[$class => ['prop' => $val]]` is no longer recognized. Callers migrate by flattening: bare names for public / protected / most-derived-private, `"\0ParentClass\0prop"` for parent-declared private. - `DEEPCLONE_HYDRATE_MANGLED_VARS` constant removed (the mode is now implicit). Drop it from `$flags` arguments. - `DEEPCLONE_HYDRATE_PRESERVE_REFS` moves from `1 << 3` to `1 << 2` (filling the slot vacated by `MANGLED_VARS`). Symbolic references via the constant name are unaffected. - The "NUL-prefixed scope key → footgun ValueError" guard is gone — NUL-prefix keys are now valid mangled keys. - A NUL-prefixed key that does not resolve to a declared property on `$class` or a parent raises `ValueError("invalid mangled key")` instead of silently creating a dynamic property. - Bare names no longer auto-resolve to parent-private slots via Reflection alone; the `propertyScopes` index handles that case automatically, and matches the ext's behavior (parent-private is reached when the bare name is declared on exactly one ancestor). ### Perf (14-prop DTO, PHP 8.4, warm caches) | shape | before | after | vs Reflection | |----------------------------|-----------:|----------:|--------------:| | flat class | 3,500 ns | 1,930 ns | 1.10× | | 3-level inheritance | 4,300 ns | 3,760 ns | 2.27× | ### Tests - Six scoped-mode-only test cases removed (integer-key / non-array / interface / unrelated / non-existing scope) — the remaining `*MatchesUnserialize` tests cover the equivalent behavior in the flat shape. - Added `testHydrateBareNameReachesParentPrivate` covering the bare-name-to-parent-private resolution via the `propertyScopes` index. - All 362 DeepClone tests pass. A separate follow-up is needed on `symfony/var-exporter` to preserve `Hydrator::hydrate()` / `Instantiator::instantiate()`'s existing `$scopedVars` / `$mangledVars` parameter BC by translating to the flat shape internally.
3193b61 to
1903547
Compare
nicolas-grekas
added a commit
to symfony/polyfill
that referenced
this pull request
Apr 16, 2026
Mirrors symfony/php-ext-deepclone#15. The scoped shape `[$className => ['prop' => $val]]` and the flat mangled shape `['prop' => $val, "\0Class\0priv" => $val2]` were functionally equivalent; keeping both required a fast path, an intermediate scoped_vars grouping for the MANGLED_VARS branch, a double-pass write, and a footgun guard. `deepclone_hydrate()` now interprets `$vars` exclusively as the flat `(array) $obj`-shape. The parser groups keys by write scope in one pass via a cached per-class `propertyScopes` index (adapted from `LazyObjectRegistry::getPropertyScopes()` in `symfony/var-exporter`) that resolves every mangled-key shape — bare `"foo"`, `"\0*\0foo"`, `"\0Class\0foo"` — with a single hash lookup and correctly points bare names at the most-derived declaring class for shadowed parent-privates. A scan-based fast path hands `$vars` straight to the `$class` hydrator when every key resolves to `$class` (the common flat-DTO shape), bypassing the intermediate grouping array. ### BC breaks - Scoped-shape input `[$class => ['prop' => $val]]` is no longer recognized. Callers migrate by flattening: bare names for public / protected / most-derived-private, `"\0ParentClass\0prop"` for parent-declared private. - `DEEPCLONE_HYDRATE_MANGLED_VARS` constant removed (the mode is now implicit). Drop it from `$flags` arguments. - `DEEPCLONE_HYDRATE_PRESERVE_REFS` moves from `1 << 3` to `1 << 2` (filling the slot vacated by `MANGLED_VARS`). Symbolic references via the constant name are unaffected. - The "NUL-prefixed scope key → footgun ValueError" guard is gone — NUL-prefix keys are now valid mangled keys. - A NUL-prefixed key that does not resolve to a declared property on `$class` or a parent raises `ValueError("invalid mangled key")` instead of silently creating a dynamic property. - Bare names no longer auto-resolve to parent-private slots via Reflection alone; the `propertyScopes` index handles that case automatically, and matches the ext's behavior (parent-private is reached when the bare name is declared on exactly one ancestor). ### Perf (14-prop DTO, PHP 8.4, warm caches) | shape | before | after | vs Reflection | |----------------------------|-----------:|----------:|--------------:| | flat class | 3,500 ns | 1,930 ns | 1.10× | | 3-level inheritance | 4,300 ns | 3,760 ns | 2.27× | ### Tests - Six scoped-mode-only test cases removed (integer-key / non-array / interface / unrelated / non-existing scope) — the remaining `*MatchesUnserialize` tests cover the equivalent behavior in the flat shape. - Added `testHydrateBareNameReachesParentPrivate` covering the bare-name-to-parent-private resolution via the `propertyScopes` index. - All 362 DeepClone tests pass. A separate follow-up is needed on `symfony/var-exporter` to preserve `Hydrator::hydrate()` / `Instantiator::instantiate()`'s existing `$scopedVars` / `$mangledVars` parameter BC by translating to the flat shape internally.
1903547 to
94b6977
Compare
nicolas-grekas
added a commit
to symfony/polyfill
that referenced
this pull request
Apr 16, 2026
Mirrors symfony/php-ext-deepclone#15. The scoped shape `[$className => ['prop' => $val]]` and the flat mangled shape `['prop' => $val, "\0Class\0priv" => $val2]` were functionally equivalent; keeping both required a fast path, an intermediate scoped_vars grouping for the MANGLED_VARS branch, a double-pass write, and a footgun guard. `deepclone_hydrate()` now interprets `$vars` exclusively as the flat `(array) $obj`-shape. The parser groups keys by write scope in one pass via a cached per-class `propertyScopes` index (adapted from `LazyObjectRegistry::getPropertyScopes()` in `symfony/var-exporter`) that resolves every mangled-key shape — bare `"foo"`, `"\0*\0foo"`, `"\0Class\0foo"` — with a single hash lookup and correctly points bare names at the most-derived declaring class for shadowed parent-privates. A scan-based fast path hands `$vars` straight to the `$class` hydrator when every key resolves to `$class` (the common flat-DTO shape), bypassing the intermediate grouping array. ### BC breaks - Scoped-shape input `[$class => ['prop' => $val]]` is no longer recognized. Callers migrate by flattening: bare names for public / protected / most-derived-private, `"\0ParentClass\0prop"` for parent-declared private. - `DEEPCLONE_HYDRATE_MANGLED_VARS` constant removed (the mode is now implicit). Drop it from `$flags` arguments. - `DEEPCLONE_HYDRATE_PRESERVE_REFS` moves from `1 << 3` to `1 << 2` (filling the slot vacated by `MANGLED_VARS`). Symbolic references via the constant name are unaffected. - The "NUL-prefixed scope key → footgun ValueError" guard is gone — NUL-prefix keys are now valid mangled keys. - A NUL-prefixed key that does not resolve to a declared property on `$class` or a parent raises `ValueError("invalid mangled key")` instead of silently creating a dynamic property. - Bare names no longer auto-resolve to parent-private slots via Reflection alone; the `propertyScopes` index handles that case automatically, and matches the ext's behavior (parent-private is reached when the bare name is declared on exactly one ancestor). ### Perf (14-prop DTO, PHP 8.4, warm caches) | shape | before | after | vs Reflection | |----------------------------|-----------:|----------:|--------------:| | flat class | 3,500 ns | 1,930 ns | 1.10× | | 3-level inheritance | 4,300 ns | 3,760 ns | 2.27× | ### Tests - Six scoped-mode-only test cases removed (integer-key / non-array / interface / unrelated / non-existing scope) — the remaining `*MatchesUnserialize` tests cover the equivalent behavior in the flat shape. - Added `testHydrateBareNameReachesParentPrivate` covering the bare-name-to-parent-private resolution via the `propertyScopes` index. - All 362 DeepClone tests pass. A separate follow-up is needed on `symfony/var-exporter` to preserve `Hydrator::hydrate()` / `Instantiator::instantiate()`'s existing `$scopedVars` / `$mangledVars` parameter BC by translating to the flat shape internally.
deepclone_hydrate() now interprets $vars exclusively as a flat mangled-key array; the per-class scoped shape is removed along with DEEPCLONE_HYDRATE_MANGLED_VARS. PRESERVE_REFS shifts from (1<<3) to (1<<2), filling the vacated slot. Also bundled follow-ups surfaced by review: - deepclone_hydrate: reject the SPL "\0" key on classes that don't support it; reject malformed SPL payloads (odd-count pairs for SplObjectStorage, >3 ctor args for ArrayObject/ArrayIterator); cache the offsetSet lookup across SplObjectStorage iterations; gate the null → uninitialized shortcut on zend_lazy_object_initialized(obj) so lazy objects don't bypass the Reflection-based write path. - deepclone_from_array: cross-validate objectMeta wakeup flags against states entries (positive → __wakeup, negative → __unserialize), rejecting impossible meta like [0, 999] or [0, -123] that used to be accepted silently; route writes to undeclared prop names on non-stdClass objects through zend_update_property_ex() to respect overridden write handlers; throw on out-of-range obj_id in "properties" entries (was silently skipped); replace the per-object obj_classes[] pointer scan with a direct class_id index, dropping an O(N × K) step.
4cbb312 to
0ecb300
Compare
nicolas-grekas
added a commit
to symfony/polyfill
that referenced
this pull request
Apr 16, 2026
…nly shape (nicolas-grekas) This PR was merged into the 1.x branch. Discussion ---------- [DeepClone] Drop scoped mode — mangled-only is now the only shape Mirrors [symfony/php-ext-deepclone#15](symfony/php-ext-deepclone#15) on the polyfill side. ## Summary The scoped shape `[$className => ['prop' => $val]]` and the flat mangled shape `['prop' => $val, "\0Class\0priv" => $val2]` were functionally equivalent; keeping both required a fast path, an intermediate `scoped_vars` grouping for the `MANGLED_VARS` branch, a double-pass write, and a footgun guard. `deepclone_hydrate()` now interprets `$vars` exclusively as the flat `(array) $obj`-shape. Key resolution is done via a cached per-class `propertyScopes` index (adapted from `LazyObjectRegistry::getPropertyScopes()` in `symfony/var-exporter`) that maps every mangled-key shape — bare `"foo"`, `"\0*\0foo"`, `"\0Class\0foo"` — to `[$declaringClass, $realName]` in a single hash lookup. Bare names resolve to the most-derived declaring class and correctly target parent-private slots when unambiguous. A scan-based fast path hands `$vars` straight to the `$class` hydrator when every key resolves to `$class` (the common flat-DTO shape), bypassing the intermediate grouping array. ## Perf (14-prop DTO, PHP 8.4, warm caches) | shape | before | after | vs Reflection | |----------------------------|-----------:|----------:|--------------:| | flat class | 3,500 ns | 1,930 ns | 1.10× | | 3-level inheritance | 4,300 ns | 3,760 ns | 2.27× | The flat-class fast path is now on par with raw `ReflectionProperty::setValue` — essentially equal; the inheritance case still pays the per-scope closure dispatch (3 closures × scope-bound), which is orthogonal to this change. ## BC breaks - Scoped-shape input `[$class => ['prop' => $val]]` is no longer recognized. Callers migrate by flattening: bare names for public / protected / most-derived-private, `"\0ParentClass\0prop"` for parent-declared private. - `DEEPCLONE_HYDRATE_MANGLED_VARS` constant removed (the mode is now implicit). Drop it from `$flags` arguments. - `DEEPCLONE_HYDRATE_PRESERVE_REFS` moves from `1 << 3` to `1 << 2` (filling the slot vacated by `MANGLED_VARS`). Symbolic references via the constant name are unaffected. - The "NUL-prefixed scope key → footgun ValueError" guard is gone — NUL-prefix keys are now valid mangled keys. - A NUL-prefixed key that does not resolve to a declared property on `$class` or a parent raises `ValueError("invalid mangled key")` instead of silently creating a dynamic property (matching the ext's "not a parent" rejection). ## Tests - Six scoped-mode-only test cases removed (integer-key / non-array / interface / unrelated / non-existing scope) — the remaining `*MatchesUnserialize` tests cover the equivalent behavior in the flat shape. - Added `testHydrateBareNameReachesParentPrivate` covering bare-name-to-parent-private resolution via the `propertyScopes` index (GP → P → C chain). - All 362 DeepClone tests pass locally. ## Follow-up (out of scope for this PR) `symfony/var-exporter`'s `Hydrator::hydrate()` / `Instantiator::instantiate()` still need a separate PR to keep their `$scopedVars` / `$mangledVars` BC parameters by translating to the flat shape internally. ## Test plan - [x] 362/362 DeepClone tests green locally - [ ] CI green across the PHP matrix Commits ------- 352b139 [DeepClone] Drop scoped mode — mangled-only is now the only shape
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.
Summary
The scoped shape
[$className => ['prop' => $val]]and the flat mangled shape['prop' => $val, "\0Class\0priv" => $val2]were functionally equivalent: both resolved each key to the sameproperty_infoentry and performed the same direct slot write. Keeping both required an intermediatescoped_propsHashTable built by theMANGLED_VARSpath, a double-pass write loop, and a footgun guard to catch users passing mangled keys without the flag.Drop scoped mode entirely.
$varsis always the flat(array)-cast shape, which is also what Doctrine-style ORM hydrators get from PDO. The parser now writes directly during the key-parse pass — noscoped_propsaccumulation, no second-pass loop.BC breaks
Scoped-shape input no longer understood.
[$class => ['prop' => $val]]is interpreted as a mangled-form input; a NUL-prefixed key that doesn't match a declared scope now raises"not a parent"/"does not declare"ValueError(see below), and a bare class name onstdClasssilently creates a dynamic property named after the class. Callers migrate by flattening:DEEPCLONE_HYDRATE_MANGLED_VARSconstant removed. The mode is now implicit — drop the flag from$flagsarguments.DEEPCLONE_HYDRATE_PRESERVE_REFSmoves from1 << 3to1 << 2(filling the slot vacated byMANGLED_VARS). Symbolic references via the constant name are unaffected.Mangled-form key → undeclared slot now rejects. A
"\0*\0prop"or"\0Class\0prop"key that doesn't resolve to a declared property on its scope raisesValueError("key scope X does not declare a Y property")instead of silently creating a dynamic property. A mangled-form key whose class isn'tobj_ceor a parent still raises the existing"key scope X is not a parent of Y"error.Code impact
deepclone.c: −480 / +330 lines indeepclone_hydrate(). The scoped-mode second-pass write loop (~200 lines), theMANGLED_VARS→scoped_propsintermediate builder (~140 lines), and the footgun guard are gone.deepclone.stub.php+ regenerateddeepclone_arginfo.h:DEEPCLONE_HYDRATE_MANGLED_VARSconstant entry removed.README.md: rewritten the$varssection around the(array)-cast shape, with a table of key shapes and their targets. The "scoped mode" examples and the "which shape is faster" paragraph are gone.CHANGELOG.md: 0.5.0 entry documenting the BC break.php_deepclone.h:PHP_DEEPCLONE_VERSION0.4.0→0.5.0.Tests
All 30 phpt tests converted and passing locally on PHP 8.4 NTS. New coverage:
GP → P → Cchain —'secret'alone writes toGP::$secret, no mangled key required)."does not declare"("\0*\0undeclared"on a user class and"\0Parent\0undeclared"on the child both rejected).Test plan
.phpttests green locally (PHP 8.4, NTS)Companion PR on
symfony/polyfill(#576) mirrors this change — same BC breaks, same error messages, same test coverage.