Skip to content

fix: translate mangled keys before hydrating from a snapshot - #1149

Open
kabylixx wants to merge 3 commits into
zenstruck:2.xfrom
kabylixx:fix/hydrate-from-snapshot-mangled-keys
Open

fix: translate mangled keys before hydrating from a snapshot#1149
kabylixx wants to merge 3 commits into
zenstruck:2.xfrom
kabylixx:fix/hydrate-from-snapshot-mangled-keys

Conversation

@kabylixx

@kabylixx kabylixx commented Aug 3, 2026

Copy link
Copy Markdown

Fixes #1148.

hydrateFromSnapshot() feeds an (array) $object cast straight to deepclone_hydrate(), but the two use different key conventions: the cast mangles non-public names ("\0Class\0name" for a private one, "\0*\0name" for a protected one) while the hydrators expect what VarExporter\Hydrator builds — a plain name for anything writable in the object's own scope, and "\0Scope\0name" only for a private property declared by another class.

When every key resolves to the object's own scope, the hydrator takes its un-grouped fast path and uses the mangled name verbatim, which fails with Error: Cannot access property starting with "\0". In practice this hits any autorefreshed object that gets deleted: autorefresh() then takes the "object no longer exists" branch and restores the snapshot.

This translates the keys before hydrating. Two details worth noting:

  • the split is on the last "\0": a property name never contains one, but an anonymous class name does (class@anonymous\0/path/to/file.php:7$0), so explode("\0", $key, 3) mis-splits those;
  • no substr/strrpos, because mb_str_functions rewrites them to their mb_* counterparts, which count characters and would make byte-level mangling encoding-dependent.

Tests: 2 of the 4 added cases fail before the patch (own private, own protected — the first one on an anonymous class, so the split above is covered). The other 2 guard the paths that already worked: public-only, and inherited properties including a private shadowed across scopes.

`hydrateFromSnapshot()` fed an `(array) $object` cast straight to the hydrators,
but the cast mangles non-public property names while the hydrators expect the
convention built by `VarExporter\Hydrator`: a plain name for anything writable
in the object's own scope, `"\0Scope\0name"` only for a private property
declared by another class.

When every key resolves to the object's own scope, the hydrator takes its
un-grouped fast path and uses the mangled name verbatim, failing with
`Error: Cannot access property starting with "\0"`.
@kabylixx
kabylixx force-pushed the fix/hydrate-from-snapshot-mangled-keys branch from 362cde8 to 8840d2f Compare August 3, 2026 11:48

@nikophil nikophil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the fix, I'm just wondering if there is not a solution out of the box using deepclone (and its polyfill) directly

Comment thread src/Object/Hydrator.php
* @test
*/
#[Test]
public function can_hydrate_from_snapshot_with_public_property_only(): void

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any chance that you also reproduce the problem in a functional test please? in \Zenstruck\Foundry\Tests\Integration\Persistence\AutoRefreshTestCase

Existing fixtures cannot reproduce the failure: they all inherit a public `$id`
from a mapped superclass, and any plain or foreign-scope key in the snapshot is
enough to take the hydrator's grouping path, where mangled names are resolved.

Adds an entity and a document declaring every property themselves, privately,
which is the shape that triggers the un-grouped fast path.
@nikophil

nikophil commented Aug 3, 2026

Copy link
Copy Markdown
Member

hey @kabylixx I think the problem is in symfony/polyfill-deepclone

I'll propose a fix there

@kabylixx

kabylixx commented Aug 3, 2026

Copy link
Copy Markdown
Author

Makes sense — and I agree the polyfill is inconsistent with itself here: its grouping path resolves "\0Class\0name" correctly, only the un-grouped fast path uses the key verbatim.

Two things that might matter for a fix landing there:

  • foundry doesn't require the polyfill directly, it comes through symfony/var-exporter, which allows ^1.40 — so a fix released as 1.41 wouldn't reach users who already have 1.40 resolved. Would you add a direct requirement, or a conflict on the affected versions?
  • bootstrap81.php returns early when ext-deepclone is loaded, so on PHP 8.5 with the extension the native implementation runs instead of the polyfill. Do you know whether it has the same fast-path behaviour? A polyfill-only fix wouldn't cover that case.

Happy to reduce this PR to just the tests if you'd rather fix it upstream — they fail against the current polyfill and would guard against a regression wherever the fix ends up living. Just say the word.

@nikophil

nikophil commented Aug 5, 2026

Copy link
Copy Markdown
Member

Hi!

what I'm planning to do is:

does that seem OK to you? I'd like to prevent monkey patching deepclone in Foundry 😅

The root cause is fixed in symfony/polyfill-deepclone, so translating the keys
here would only work around it. The tests stay and fail until a polyfill release
carries the fix.
@kabylixx

kabylixx commented Aug 7, 2026

Copy link
Copy Markdown
Author

Sounds good to me — and I'd rather not monkey-patch deepclone from Foundry either.

I've dropped normalizeMangledKeys() from this PR and kept only the tests, so it's ready to merge once the polyfill is tagged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] 2.11.1: Cannot access property starting with "\0"hydrateFromSnapshot() passes (array)-mangled keys to deepclone_hydrate()

2 participants