A PHP extension that deep-clones any serializable PHP value while preserving
copy-on-write for strings and arrays — resulting in lower memory usage and
better performance than unserialize(serialize()).
It works by converting the value graph to a pure-array representation (only
scalars and nested arrays, no objects) and back. This array form is the wire
format used by Symfony's
VarExporter\DeepCloner,
making the extension a transparent drop-in accelerator.
Repeated cloning of a prototype. Calling unserialize(serialize()) in a
loop allocates fresh copies of every string and array, blowing up memory.
This extension preserves PHP's copy-on-write: strings and scalar arrays are
shared between clones until they are actually modified.
$payload = deepclone_to_array($prototype);
for ($i = 0; $i < 1000; $i++) {
$clone = deepclone_from_array($payload); // fast, COW-friendly
}OPcache-friendly cache format. The pure-array payload is suitable for
var_export(). When cached in a .php file, OPcache maps it into shared
memory — making the "unserialize" step essentially free:
// Write:
file_put_contents('cache.php', '<?php return ' . var_export(deepclone_to_array($graph), true) . ';');
// Read (OPcache serves this from SHM):
$clone = deepclone_from_array(require 'cache.php');Serialization to any format. The array form can be passed to
json_encode(), MessagePack, igbinary, APCu, or any transport that handles
plain PHP arrays — without losing object identity, cycles, references, or
private property state.
$payload = deepclone_to_array($graph);
$json = json_encode($payload); // safe — no objects in the array
// ... send over the wire, store in a DB, etc.
$clone = deepclone_from_array(json_decode($json, true));Fast object instantiation and hydration. Create objects and set their properties — including private, protected, and readonly ones — without calling their constructor, faster than Reflection:
// Flat bare-name array — ideal for hydrating from a flat row
// (e.g. a PDO result).
$user = deepclone_hydrate(User::class, [
'id' => 42,
'name' => 'Alice',
'email' => 'alice@example.com',
]);
// Mangled keys for parent-declared private properties — same format as
// (array) $obj cast produces.
$user = deepclone_hydrate(User::class, [
'name' => 'Alice',
"\0AbstractEntity\0createdAt" => new \DateTimeImmutable(),
]);
// Hydrate an existing object
deepclone_hydrate($existingUser, ['name' => 'Bob']);function deepclone_to_array(mixed $value, ?array $allowed_classes = null): array;
function deepclone_from_array(array $data, ?array $allowed_classes = null): mixed;
function deepclone_hydrate(object|string $object_or_class, array $vars = [], int $flags = 0): object;$allowed_classes restricts which classes may be serialized or deserialized
(null = allow all, [] = allow none). Case-insensitive, matching
unserialize()'s allowed_classes option. Closures require "Closure" in
the list.
deepclone_hydrate() accepts either an object to hydrate in place or a class
name to instantiate without calling its constructor. By default, PHP &
references in $vars are dropped on write; pass DEEPCLONE_HYDRATE_PRESERVE_REFS
to keep them.
$vars is a flat array keyed by property name — the exact shape
(array) $obj produces:
| key shape | target |
|---|---|
"propName" |
public, protected (any declaring class), or private declared on the object's own class |
"\0*\0propName" |
protected (the declaring class is resolved via the object) |
"\0ClassName\0propName" |
private declared on ClassName — must be the object's own class or a parent |
"\0" |
SPL internal state (SplObjectStorage / ArrayObject / ArrayIterator) |
Each key triggers one properties_info hash lookup followed by a direct
slot write.
$user = deepclone_hydrate(User::class, [
'id' => 42, // bare — public or own-private
'name' => 'Alice',
"\0*\0createdAt" => new \DateTimeImmutable(), // protected
"\0AbstractEntity\0metadata" => [...], // parent-private
]);Bare names are enough for every public, protected, or most-derived-private
property. Parent-declared private properties need the explicit
"\0ClassName\0prop" mangled form (the engine keys them that way in the
child's properties_info).
$flags selects the write semantics for declared-property assignments:
| Flag | Semantics |
|---|---|
0 (default) |
ReflectionProperty::setRawValue — bypass set hooks, type-check, respect readonly |
DEEPCLONE_HYDRATE_CALL_HOOKS |
ReflectionProperty::setValue — invoke set hooks |
DEEPCLONE_HYDRATE_NO_LAZY_INIT |
ReflectionProperty::setRawValueWithoutLazyInitialization — skip the lazy initializer; realize the object when the last lazy property is set |
DEEPCLONE_HYDRATE_PRESERVE_REFS |
preserve PHP & references from $vars onto the target property slots; by default, references are dropped (dereferenced) on write |
DEEPCLONE_HYDRATE_CALL_HOOKS and DEEPCLONE_HYDRATE_NO_LAZY_INIT are
mutually exclusive; PRESERVE_REFS composes with either.
deepclone_from_array() always uses the default setRawValue semantics,
mirroring unserialize().
PRESERVE_REFS is off by default because preserving references requires a
per-call probe of the input array, which costs more than the typical DTO
hydration saves by using the ext over Reflection. Pass the flag when you
actually need a property slot to remain aliased to a caller-side variable or
to another property (e.g. when rehydrating a graph previously exported with
deepclone_to_array() that contained & references).
deepclone_hydrate() applies three coercions before writing each
declared property, so common rehydration patterns don't trip on
strict-type errors. They run under every mode unless noted:
- Readonly idempotent skip — when the readonly slot already holds an
identical value (
===), the write is silently skipped. AvoidsError: Cannot modify readonly propertyon no-op rehydration. Different values still raise the engine's normal error. null→unset()for non-nullable typed properties — writingnullinto a non-nullable typed slot stores the uninitialized state (soReflectionProperty::isInitialized()returnsfalseand reads raise the standard "must not be accessed before initialization" error) instead of throwingTypeError. This restores a state otherwise unreachable through hydration. Nullable /mixedtypes keep their existing semantics. Hooked properties never trigger this rule (no backing slot to "unset" semantically; a set hook may handlenullitself).- Scalar → backed-enum cast — when the property is typed with a
single (possibly nullable) backed enum and the payload value is a
scalar matching the enum's backing type (
int↔ int-backed,string↔ string-backed), the value is cast to the corresponding case. Unknown backing values raise the standardValueError("X is not a valid backing value for enum Y"), matchingEnum::from(). Union/intersection types on the property itself are left untouched. The decision rests on the property type only — hook presence andDEEPCLONE_HYDRATE_CALL_HOOKSmode don't change it. Set hooks on enum-typed properties accordingly receive the enum case, not the raw scalar.
The special "\0" key sets the internal state of SPL classes:
// ArrayObject / ArrayIterator — ["\0" => [$array, $flags?, $iteratorClass?]]
$ao = deepclone_hydrate('ArrayObject', [
"\0" => [['x' => 1, 'y' => 2], ArrayObject::ARRAY_AS_PROPS],
]);
// SplObjectStorage — ["\0" => [$obj1, $info1, $obj2, $info2, ...]]
$s = deepclone_hydrate('SplObjectStorage', [
"\0" => [$obj, 'metadata'],
]);- Object identity (shared references stay shared)
- PHP
&hard references - Cycles in the object graph
- Private/protected properties across inheritance
__serialize/__unserialize/__sleep/__wakeupsemantics- Named closures (first-class callables like
strlen(...)) - Enum values
- Copy-on-write for strings and scalar arrays
| Exception | Thrown by | When |
|---|---|---|
DeepClone\NotInstantiableException |
deepclone_to_array, deepclone_hydrate |
Resource, anonymous class, Reflection*, internal class without serialization support |
DeepClone\ClassNotFoundException |
deepclone_from_array, deepclone_hydrate |
Payload/class name references a class that doesn't exist |
ValueError |
all three | Malformed input, or class not in $allowed_classes |
Both exception classes extend \InvalidArgumentException.
- PHP 8.2+ (NTS or ZTS, 64-bit and 32-bit)
pie install symfony/deepcloneThen enable in php.ini:
extension=deepclonegit clone https://github.com/symfony/php-ext-deepclone.git
cd php-ext-deepclone
phpize && ./configure --enable-deepclone && make && make test
sudo make installsymfony/var-exporter and symfony/polyfill-deepclone provide the same
deepclone_to_array(), deepclone_from_array(), and deepclone_hydrate()
functions in pure PHP. When this extension is loaded it replaces the polyfill
transparently — no code change needed.
Symfony's Hydrator::hydrate() and Instantiator::instantiate() delegate
directly to deepclone_hydrate(), making them thin one-liner wrappers.
Released under the MIT license.