Back to blog

Closing out myclabs/deep-copy: cycles, filters, and the last 17 percent

myclabs/deep-copy is the library most PHP codebases reach for the moment PHP's own clone keyword stops being enough. It walks an object graph and produces a genuinely independent copy, nested objects included, which plain clone does not do since PHP only clones the top-level object shallowly. It ships inside PHPUnit itself, where it isolates test doubles between assertions, and Doctrine relies on it for entity cloning where a shallow copy would leave two entities sharing the same nested collections.

The package was first mentioned in the 4 May recap, sitting at 83% on the back of a full clone operator implementation in Pext's runtime core. That earlier post covered getting __clone, property copying, and reference preservation right. The remaining 17% turned out to be entirely in deep-copy's own logic on top of clone: how it detects cycles, how its filter chain dispatches, and how it enumerates properties through reflection. This post closes the library out to 100%.

Circular object graphs and identity

deep-copy has to handle object graphs that reference themselves: object A holds a reference to B, B holds a reference back to A. A naive recursive copier walks into that cycle and never returns. The library's fix is a hashMap keyed by spl_object_hash() that records every object already cloned, so when the recursion reaches an object it has seen before it returns the existing clone instead of recursing again.

Porting that map to Pext exposed a subtlety in what "the same object" means at runtime. A PHP object's Pext-side representation can be wrapped or unwrapped at different call sites depending on how it arrived at that point in the call graph, so keying the visited map by JS object reference equality missed cycles that spl_object_hash() would have caught in PHP. The fix was to key the map by Pext's own canonical object identity, the same stable identifier the object proxy layer already exposes internally, rather than by whatever wrapper happened to be in hand at that call site.

Once the visited map used canonical identity instead of reference equality, the recursive-structure tests in the suite passed cleanly: A-holds-B-holds-A, longer cycles through several intermediate objects, and self-referential single-object cycles all terminated with the same shared-clone semantics PHP produces.

The filter and matcher chain

deep-copy's real feature surface is DeepCopy::addFilter($filter, $matcher). A matcher decides which properties on an object a filter should run against, by property name, by declared type, or by an arbitrary custom closure, and the filter then controls how that property is cloned instead of falling through to the default behavior. This is what lets callers exclude a property from cloning entirely, replace a value with a fresh instance, or share a reference instead of copying.

Callers pass matchers and filters as plain closures more often than as objects implementing the library's interfaces, and those closures get stored in an array on the DeepCopy instance and invoked much later, well after the call site that defined them has returned. That is exactly the shape Pext's first-class-callable handling needs to get right: a closure captured into a data structure and called from a completely different point in the program, potentially with the defining scope long gone from the top of any call stack.

Getting closures-in-arrays solid closed most of the filter-chain failures at once, since nearly every custom matcher in the test suite is written as an inline closure rather than a dedicated matcher class. The remaining failures in that area were ordering bugs where filters registered later were expected to take precedence over earlier ones for overlapping matchers, which was a matter of matching deep-copy's own iteration order rather than a Pext limitation.

Reflection-based property enumeration and visibility

To decide what to clone, deep-copy walks an object's declared properties through reflection, including private and protected properties declared on parent classes rather than on the object's own class. PHP's reflection API makes that walk explicit: private properties are only visible from a ReflectionClass built against the declaring class, not the child class, so the library has to climb the class hierarchy itself to collect the full property set.

That is precisely the surface Pext's __reflect.props visibility filter exists to handle, the same mechanism that already backs get_object_vars and foreach over objects with correct scope-relative visibility. deep-copy's cloning correctness ended up exercising that filter from a different angle than the tests that originally drove it: instead of a single call site checking visibility against its own scope, deep-copy walks the entire inheritance chain, accumulating private properties per declaring class and protected properties across the hierarchy, then asks Pext's reflection to report the correct owner and visibility for each one.

The failures here were narrow: a couple of cases where a private property redeclared in a subclass with the same name as a parent's private property was being merged into one slot instead of kept as two independent properties belonging to two different declaring classes. Fixing that in the reflection layer's property enumeration made deep-copy's parent-and-child private property cloning match PHP exactly, and it strengthened the same code path that get_object_vars and foreach depend on.

Where we are

myclabs/deep-copy is now at 100%. The gap between the 83% reported in May and full parity closed out to be entirely in the library's own logic on top of a correct clone implementation: identity-keyed cycle detection, closures used as filters and matchers, and reflection-driven property enumeration across inheritance. It joins the growing list of cleared packages on the open source showcase, which tracks live pass rates as each one closes.