Skip to content

Drop scoped mode — mangled-only is now the only shape (v0.5.0)#15

Merged
nicolas-grekas merged 1 commit intomainfrom
drop-scoped-mode
Apr 16, 2026
Merged

Drop scoped mode — mangled-only is now the only shape (v0.5.0)#15
nicolas-grekas merged 1 commit intomainfrom
drop-scoped-mode

Conversation

@nicolas-grekas
Copy link
Copy Markdown
Member

@nicolas-grekas nicolas-grekas commented Apr 15, 2026

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 same property_info entry and performed the same direct slot write. Keeping both required an intermediate scoped_props HashTable built by the MANGLED_VARS path, a double-pass write loop, and a footgun guard to catch users passing mangled keys without the flag.

Drop scoped mode entirely. $vars is 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 — no scoped_props accumulation, 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 on stdClass silently creates a dynamic property named after the class. Callers migrate by flattening:

    // before
    deepclone_hydrate(User::class, [
        User::class => ['id' => 42, 'name' => 'Alice'],
        AbstractEntity::class => ['metadata' => [...]],
    ]);
    
    // after
    deepclone_hydrate(User::class, [
        'id' => 42,
        'name' => 'Alice',
        "\0AbstractEntity\0metadata" => [...],
    ]);
  • DEEPCLONE_HYDRATE_MANGLED_VARS constant removed. The mode is now implicit — drop the flag 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.

  • 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 raises ValueError("key scope X does not declare a Y property") instead of silently creating a dynamic property. A mangled-form key whose class isn't obj_ce or a parent still raises the existing "key scope X is not a parent of Y" error.

Code impact

  • deepclone.c: −480 / +330 lines in deepclone_hydrate(). The scoped-mode second-pass write loop (~200 lines), the MANGLED_VARS→scoped_props intermediate builder (~140 lines), and the footgun guard are gone.
  • deepclone.stub.php + regenerated deepclone_arginfo.h: DEEPCLONE_HYDRATE_MANGLED_VARS constant entry removed.
  • README.md: rewritten the $vars section 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_VERSION 0.4.00.5.0.

Tests

All 30 phpt tests converted and passing locally on PHP 8.4 NTS. New coverage:

  • Bare name reaches a parent-private slot when the name is unambiguous (GP → P → C chain — 'secret' alone writes to GP::$secret, no mangled key required).
  • Mangled-form key with undeclared prop raises "does not declare" ("\0*\0undeclared" on a user class and "\0Parent\0undeclared" on the child both rejected).

Test plan

  • 30/30 .phpt tests green locally (PHP 8.4, NTS)
  • CI green on the matrix (Linux NTS/ZTS/i386/debug, macOS, Windows)

Companion PR on symfony/polyfill (#576) mirrors this change — same BC breaks, same error messages, same test coverage.

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.
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.
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 nicolas-grekas force-pushed the drop-scoped-mode branch 2 times, most recently from 3193b61 to 1903547 Compare April 16, 2026 08:18
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.
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.
@nicolas-grekas nicolas-grekas merged commit 0ecb300 into main Apr 16, 2026
20 checks passed
@nicolas-grekas nicolas-grekas deleted the drop-scoped-mode branch April 16, 2026 12:09
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
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