[php-src] Issue #22979: serialize() with lazy proxies duplicates the real instance instead of using a backreference
[email protected] (bmack) Fri, 31 Jul 2026 18:33:33 +0000
| Newsgroups | php.bugs |
|---|---|
| Message-ID | <[email protected]> |
Issue: https://github.com/php/php-src/issues/22979
Author: bmack
### Description
While migrating TYPO3 Extbase lazy loading to native lazy objects (https://forge.typo3.org/issues/110347) I noticed serialize() doesn't unify a lazy proxy with its real instance:
```php
class Entity {
public int $value = 42;
}
$r = new ReflectionClass(Entity::class);
$real = new Entity();
$proxy = $r->newLazyProxy(fn (): Entity => $real);
$restored = unserialize(serialize([$proxy, $real]));
var_dump($restored[0] === $restored[1]); // false
```
payload is `a:2:{i:0;O:6:"Entity":1:{s:5:"value";i:42;}i:1;O:6:"Entity":1:{s:5:"value";i:42;}}` - two full copies, no r: reference. Doing the same with `[$real, $real]` gives `r:2` and identity survives unserialize. Same result when the proxy was already initialized before calling serialize.
Gets worse with cycles, every proxy embeds another full copy of the graph:
```php
class Post { public array $comments = []; }
class Comment { public ?Post $post = null; }
$r = new ReflectionClass(Post::class);
$post = new Post();
for ($i = 0; $i < 3; $i++) {
$c = new Comment();
$c->post = $r->newLazyProxy(fn (): Post => $post);
$post->comments[] = $c;
}
$restored = unserialize(serialize($post));
var_dump($restored->comments[0]->post === $restored); // false
var_dump($restored->comments[0]->post === $restored->comments[1]->post); // also false!
```
so after the roundtrip every comment points to a different Post copy, none of them is the root.
In our real object graphs in TYPO3 (extbase entities with lazy collections and lazy proxies) this even produced payloads that unserialize() rejects completely with `unserialize(): Error at offset 12869 of 62854 bytes` - serialize emitted a `r:249` at a point where the value counter was only at 246. Cant reproduce that part in a small script yet, happy to provide the bigger repro if needed.
Ghosts are fine btw, only proxies are affected.
Workaround we use now in TYPO3 core: implement __serialize() and initialize lazy objects there explicitly via ReflectionClass::initializeLazyObject(), so the engine serializer never runs into an unintialized lazy object itself.
### PHP Version
tested on 8.4, 8.5.5 and 8.5.7, all same behaviour
### Operating System
macOS + Linux