diff --git a/.github/patch-types.php b/.github/patch-types.php index 2de1cc5eb22..41dd0cd55fe 100644 --- a/.github/patch-types.php +++ b/.github/patch-types.php @@ -54,6 +54,7 @@ foreach ($loader->getClassMap() as $class => $file) { case false !== strpos($file, '/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/WhenTestWithClosure.php'): case false !== strpos($file, '/src/Symfony/Component/Validator/Tests/Fixtures/NestedAttribute/Entity.php'): case false !== strpos($file, '/src/Symfony/Component/VarDumper/Tests/Fixtures/NotLoadableClass.php'): + case false !== strpos($file, '/src/Symfony/Component/VarExporter/DeepCloner'): case false !== strpos($file, '/src/Symfony/Component/VarExporter/Internal'): case false !== strpos($file, '/src/Symfony/Component/VarExporter/Tests/Fixtures/'): case false !== strpos($file, '/src/Symfony/Contracts/'): diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/ContainerBuilderDebugDumpPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/ContainerBuilderDebugDumpPass.php index 456305bc95f..28bf06cb74f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/ContainerBuilderDebugDumpPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/ContainerBuilderDebugDumpPass.php @@ -18,6 +18,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Dumper\XmlDumper; use Symfony\Component\DependencyInjection\ParameterBag\EnvPlaceholderParameterBag; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\VarExporter\DeepCloner; /** * Dumps the ContainerBuilder to a cache file so that it can be used by @@ -48,11 +49,13 @@ class ContainerBuilderDebugDumpPass implements CompilerPassInterface $file = substr_replace($file, '.ser', -4); try { - $dump = new ContainerBuilder(clone $container->getParameterBag()); - $dump->setDefinitions(unserialize(serialize($container->getDefinitions()))); + $bag = $container->getParameterBag(); + $dump = new ContainerBuilder($bag); + $dump->setDefinitions($container->getDefinitions()); $dump->setAliases($container->getAliases()); - if (($bag = $container->getParameterBag()) instanceof EnvPlaceholderParameterBag) { + if ($bag instanceof EnvPlaceholderParameterBag) { + $dump = DeepCloner::deepClone($dump); (new ResolveEnvPlaceholdersPass(null))->process($dump); $dump->__construct(new EnvPlaceholderParameterBag($container->resolveEnvPlaceholders($this->escapeParameters($bag->all())))); } diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index e0801ef67a0..e1012387f07 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -31,7 +31,8 @@ "symfony/http-kernel": "^7.4|^8.0", "symfony/polyfill-mbstring": "^1.0", "symfony/polyfill-php85": "^1.33", - "symfony/routing": "^7.4|^8.0" + "symfony/routing": "^7.4|^8.0", + "symfony/var-exporter": "^8.1" }, "require-dev": { "doctrine/persistence": "^1.3|^2|^3", diff --git a/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php b/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php index c9ee94ee9c9..8399c966f40 100644 --- a/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php @@ -18,6 +18,7 @@ use Psr\Log\LoggerAwareTrait; use Symfony\Component\Cache\CacheItem; use Symfony\Component\Cache\Exception\InvalidArgumentException; use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\VarExporter\DeepCloner; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\NamespacedPoolInterface; @@ -174,8 +175,12 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, NamespacedPoolIn return true; } } - if ($this->storeSerialized && null === $value = $this->freeze($value, $key)) { - return false; + if ($this->storeSerialized) { + try { + $value = $this->freeze($value, $key); + } catch (\ValueError) { + return false; + } } if (null === $expiry && 0 < $this->defaultLifetime) { $expiry = $this->defaultLifetime; @@ -271,12 +276,10 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, NamespacedPoolIn $values = $this->values; foreach ($values as $k => $v) { - if (null === $v || 'N;' === $v) { + if (null === $v) { continue; } - if (!\is_string($v) || !isset($v[2]) || ':' !== $v[1]) { - $values[$k] = serialize($v); - } + $values[$k] = serialize($v instanceof DeepCloner ? $v->clone() : $v); } return $values; @@ -324,57 +327,43 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, NamespacedPoolIn } } - private function freeze($value, string $key): string|int|float|bool|array|\UnitEnum|null + /** + * @throws \ValueError When the value cannot be frozen + */ + private function freeze(mixed $value, string $key): mixed { - if (null === $value) { - return 'N;'; - } - if (\is_string($value)) { - // Serialize strings if they could be confused with serialized objects or arrays - if ('N;' === $value || (isset($value[2]) && ':' === $value[1])) { - return serialize($value); + try { + $cloner = new DeepCloner($value); + } catch (\Exception $e) { + if (!isset($this->expiries[$key])) { + unset($this->values[$key]); } - } elseif (!\is_scalar($value)) { - try { - $serialized = serialize($value); - } catch (\Exception $e) { - if (!isset($this->expiries[$key])) { - unset($this->values[$key]); - } - $type = get_debug_type($value); - $message = \sprintf('Failed to save key "{key}" of type %s: %s', $type, $e->getMessage()); - CacheItem::log($this->logger, $message, ['key' => $key, 'exception' => $e, 'cache-adapter' => get_debug_type($this)]); + $type = get_debug_type($value); + $message = \sprintf('Failed to save key "{key}" of type %s: %s', $type, $e->getMessage()); + CacheItem::log($this->logger, $message, ['key' => $key, 'exception' => $e, 'cache-adapter' => get_debug_type($this)]); - return null; - } - // Keep value serialized if it contains any objects or any internal references - if ('C' === $serialized[0] || 'O' === $serialized[0] || preg_match('/;[OCRr]:[1-9]/', $serialized)) { - return $serialized; - } + throw new \ValueError(); } - return $value; + return $cloner->isStaticValue() ? $value : $cloner; } private function unfreeze(string $key, bool &$isHit): mixed { - if ('N;' === $value = $this->values[$key]) { - return null; - } - if (\is_string($value) && isset($value[2]) && ':' === $value[1]) { + $value = $this->values[$key]; + + if ($value instanceof DeepCloner) { try { - $value = unserialize($value); + return $value->clone(); } catch (\Exception $e) { - CacheItem::log($this->logger, 'Failed to unserialize key "{key}": '.$e->getMessage(), ['key' => $key, 'exception' => $e, 'cache-adapter' => get_debug_type($this)]); - $value = false; - } - if (false === $value) { - $value = null; + CacheItem::log($this->logger, 'Failed to clone key "{key}": '.$e->getMessage(), ['key' => $key, 'exception' => $e, 'cache-adapter' => get_debug_type($this)]); $isHit = false; if (!$this->maxItems) { $this->values[$key] = null; } + + return null; } } diff --git a/src/Symfony/Component/Cache/composer.json b/src/Symfony/Component/Cache/composer.json index ae8f11a7876..52b3c474f56 100644 --- a/src/Symfony/Component/Cache/composer.json +++ b/src/Symfony/Component/Cache/composer.json @@ -26,7 +26,7 @@ "psr/log": "^1.1|^2|^3", "symfony/cache-contracts": "^3.6", "symfony/service-contracts": "^2.5|^3", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/var-exporter": "^8.1" }, "require-dev": { "cache/integration-tests": "dev-master", diff --git a/src/Symfony/Component/DependencyInjection/Compiler/ResolveDecoratorStackPass.php b/src/Symfony/Component/DependencyInjection/Compiler/ResolveDecoratorStackPass.php index d41442047ca..6a7ff2fc75c 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/ResolveDecoratorStackPass.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/ResolveDecoratorStackPass.php @@ -18,6 +18,7 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\VarExporter\DeepCloner; /** * @author Nicolas Grekas
@@ -89,7 +90,7 @@ class ResolveDecoratorStackPass implements CompilerPassInterface foreach ($stacks[$id] as $k => $definition) { if ($definition instanceof ChildDefinition && isset($stacks[$definition->getParent()])) { $path[] = $definition->getParent(); - $definition = unserialize(serialize($definition)); // deep clone + $definition = DeepCloner::deepClone($definition); } elseif ($definition instanceof Definition) { $definitions[$decoratedId = $prefix.$k] = $definition; continue; diff --git a/src/Symfony/Component/DependencyInjection/Compiler/ResolveInstanceofConditionalsPass.php b/src/Symfony/Component/DependencyInjection/Compiler/ResolveInstanceofConditionalsPass.php index 5e9d5038d02..da4d4cf924a 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/ResolveInstanceofConditionalsPass.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/ResolveInstanceofConditionalsPass.php @@ -16,6 +16,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Exception\RuntimeException; +use Symfony\Component\VarExporter\DeepCloner; /** * Applies instanceof conditionals to definitions. @@ -105,15 +106,7 @@ class ResolveInstanceofConditionalsPass implements CompilerPassInterface $bindings = $definition->getBindings(); $abstract = $container->setDefinition('.abstract.instanceof.'.$id, $definition); $definition->setBindings([]); - $definition = serialize($definition); - - if (Definition::class === $abstract::class) { - // cast Definition to ChildDefinition - $definition = substr_replace($definition, '53', 2, 2); - $definition = substr_replace($definition, 'Child', 44, 0); - } - /** @var ChildDefinition $definition */ - $definition = unserialize($definition); + $definition = (new DeepCloner($definition))->cloneAs(ChildDefinition::class); $definition->setParent($parent); if (null !== $shared && !isset($definition->getChanges()['shared'])) { diff --git a/src/Symfony/Component/DependencyInjection/Loader/Configurator/PrototypeConfigurator.php b/src/Symfony/Component/DependencyInjection/Loader/Configurator/PrototypeConfigurator.php index 95bf8997598..641b57f921a 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/Configurator/PrototypeConfigurator.php +++ b/src/Symfony/Component/DependencyInjection/Loader/Configurator/PrototypeConfigurator.php @@ -13,6 +13,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\VarExporter\DeepCloner; /** * @author Nicolas Grekas
@@ -54,7 +55,7 @@ class PrototypeConfigurator extends AbstractServiceConfigurator $definition->setAutowired($defaults->isAutowired()); $definition->setAutoconfigured($defaults->isAutoconfigured()); // deep clone, to avoid multiple process of the same instance in the passes - $definition->setBindings(unserialize(serialize($defaults->getBindings()))); + $definition->setBindings(DeepCloner::deepClone($defaults->getBindings())); $definition->setChanges([]); parent::__construct($parent, $definition, $namespace, $defaults->getTags()); diff --git a/src/Symfony/Component/DependencyInjection/Loader/Configurator/ServicesConfigurator.php b/src/Symfony/Component/DependencyInjection/Loader/Configurator/ServicesConfigurator.php index 06cbea40eeb..7ea838b3ef3 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/Configurator/ServicesConfigurator.php +++ b/src/Symfony/Component/DependencyInjection/Loader/Configurator/ServicesConfigurator.php @@ -18,6 +18,7 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\VarExporter\DeepCloner; /** * @author Nicolas Grekas
@@ -87,7 +88,7 @@ class ServicesConfigurator extends AbstractConfigurator
$definition->setAutowired($defaults->isAutowired());
$definition->setAutoconfigured($defaults->isAutoconfigured());
// deep clone, to avoid multiple process of the same instance in the passes
- $definition->setBindings(unserialize(serialize($defaults->getBindings())));
+ $definition->setBindings(DeepCloner::deepClone($defaults->getBindings()));
$definition->setChanges([]);
$configurator = new ServiceConfigurator($this->container, $this->instanceof, true, $this, $definition, $id, $defaults->getTags(), $this->path);
diff --git a/src/Symfony/Component/DependencyInjection/Loader/ContentLoaderTrait.php b/src/Symfony/Component/DependencyInjection/Loader/ContentLoaderTrait.php
index 27a2009ba4e..fbeb54c04c0 100644
--- a/src/Symfony/Component/DependencyInjection/Loader/ContentLoaderTrait.php
+++ b/src/Symfony/Component/DependencyInjection/Loader/ContentLoaderTrait.php
@@ -26,6 +26,7 @@ use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\ExpressionLanguage\Expression;
+use Symfony\Component\VarExporter\DeepCloner;
use Symfony\Component\Yaml\Tag\TaggedValue;
/**
@@ -647,7 +648,7 @@ trait ContentLoaderTrait
if (isset($defaults['bind']) || isset($service['bind'])) {
// deep clone, to avoid multiple process of the same instance in the passes
$bindings = $definition->getBindings();
- $bindings += isset($defaults['bind']) ? unserialize(serialize($defaults['bind'])) : [];
+ $bindings += isset($defaults['bind']) ? DeepCloner::deepClone($defaults['bind']) : [];
if (isset($service['bind'])) {
if (!\is_array($service['bind'])) {
diff --git a/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php b/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php
index f667e7b7b44..25e0ae6b0a5 100644
--- a/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php
+++ b/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php
@@ -28,6 +28,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Exception\LogicException;
+use Symfony\Component\VarExporter\DeepCloner;
/**
* FileLoader is the abstract class used by all built-in loaders that are file based.
@@ -129,26 +130,7 @@ abstract class FileLoader extends BaseFileLoader
$autoconfigureAttributes = new RegisterAutoconfigureAttributesPass();
$autoconfigureAttributes = $autoconfigureAttributes->accept($prototype) ? $autoconfigureAttributes : null;
$classes = $this->findClasses($namespace, $resource, (array) $exclude, $source);
-
- $getPrototype = static fn () => clone $prototype;
- $serialized = serialize($prototype);
-
- // avoid deep cloning if no definitions are nested
- if (strpos($serialized, 'O:48:"Symfony\Component\DependencyInjection\Definition"', 55)
- || strpos($serialized, 'O:53:"Symfony\Component\DependencyInjection\ChildDefinition"', 55)
- ) {
- // prepare for deep cloning
- foreach (['Arguments', 'Properties', 'MethodCalls', 'Configurator', 'Factory', 'Bindings'] as $key) {
- $serialized = serialize($prototype->{'get'.$key}());
-
- if (strpos($serialized, 'O:48:"Symfony\Component\DependencyInjection\Definition"')
- || strpos($serialized, 'O:53:"Symfony\Component\DependencyInjection\ChildDefinition"')
- ) {
- $getPrototype = static fn () => $getPrototype()->{'set'.$key}(unserialize($serialized));
- }
- }
- }
- unset($serialized);
+ $getPrototype = (new DeepCloner($prototype))->clone(...);
foreach ($classes as $class => $errorMessage) {
if (null === $errorMessage && $autoconfigureAttributes) {
diff --git a/src/Symfony/Component/DependencyInjection/composer.json b/src/Symfony/Component/DependencyInjection/composer.json
index 0209644930c..d90341fb854 100644
--- a/src/Symfony/Component/DependencyInjection/composer.json
+++ b/src/Symfony/Component/DependencyInjection/composer.json
@@ -20,7 +20,7 @@
"psr/container": "^1.1|^2.0",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/service-contracts": "^3.6",
- "symfony/var-exporter": "^7.4|^8.0"
+ "symfony/var-exporter": "^8.1"
},
"require-dev": {
"symfony/config": "^7.4|^8.0",
diff --git a/src/Symfony/Component/Form/Flow/DataStorage/SessionDataStorage.php b/src/Symfony/Component/Form/Flow/DataStorage/SessionDataStorage.php
index aa5f065bf6e..95b8c52604d 100644
--- a/src/Symfony/Component/Form/Flow/DataStorage/SessionDataStorage.php
+++ b/src/Symfony/Component/Form/Flow/DataStorage/SessionDataStorage.php
@@ -12,6 +12,7 @@
namespace Symfony\Component\Form\Flow\DataStorage;
use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\VarExporter\DeepCloner;
/**
* @author Yonel Ceruto
+ */
+final class DeepCloner
+{
+ private readonly mixed $value;
+ private readonly mixed $prepared;
+ private readonly array $objectMeta;
+ private readonly array $properties;
+ private readonly array $resolve;
+ private readonly array $states;
+ private readonly array $refs;
+ private readonly array $originals;
+
+ /**
+ * @param T $value
+ */
+ public function __construct(mixed $value)
+ {
+ if (!\is_object($value) && !(\is_array($value) && $value) || $value instanceof \UnitEnum) {
+ $this->value = $value;
+
+ return;
+ }
+
+ $objectsPool = new \SplObjectStorage();
+ $refsPool = [];
+ $objectsCount = 0;
+ $isStatic = true;
+ $refs = [];
+
+ try {
+ $prepared = Exporter::prepare([$value], $objectsPool, $refsPool, $objectsCount, $isStatic)[0];
+ } finally {
+ foreach ($refsPool as $i => $v) {
+ if ($v[0]->count) {
+ $refs[1 + $i] = $v[2];
+ }
+ $v[0] = $v[1];
+ }
+ }
+
+ if ($isStatic) {
+ $this->value = $value;
+
+ return;
+ }
+
+ $canCloneAll = true;
+ $originals = [];
+ $objectMeta = [];
+ $properties = [];
+ $resolve = [];
+ $states = [];
+
+ foreach ($objectsPool as $v) {
+ [$id, $class, $props, $wakeup] = $objectsPool[$v];
+
+ if (':' !== ($class[1] ?? null)) {
+ // Pre-warm Registry caches so reconstruct() only reads them
+ Registry::$reflectors[$class] ??= Registry::getClassReflector($class);
+ }
+
+ $objectMeta[$id] = [$class, $wakeup];
+
+ if (0 < $wakeup) {
+ $states[$wakeup] = $id;
+ $canCloneAll = false;
+ } elseif (0 > $wakeup) {
+ $states[-$wakeup] = [$id, $props];
+ $props = [];
+ $canCloneAll = false;
+ }
+
+ if ($canCloneAll && (':' === ($class[1] ?? null) || !Registry::$cloneable[$class])) {
+ $canCloneAll = false;
+ }
+
+ if ($canCloneAll) {
+ $originals[$id] = clone $v;
+ }
+
+ foreach ($props as $scope => $scopeProps) {
+ foreach ($scopeProps as $name => $propValue) {
+ $properties[$scope][$name][$id] = $propValue;
+ if ($propValue instanceof Reference || $propValue instanceof NamedClosure || \is_array($propValue) && self::hasReference($propValue)) {
+ $resolve[$scope][$name][] = $id;
+ }
+ }
+ }
+ }
+
+ ksort($states);
+
+ $this->prepared = $prepared instanceof Reference && $prepared->id >= 0 && !$prepared->count ? $prepared->id : $prepared;
+ $this->objectMeta = $objectMeta;
+ $this->properties = $properties;
+ $this->resolve = $resolve;
+ $this->states = $states;
+ $this->refs = $refs;
+ $this->originals = $canCloneAll ? $originals : [];
+ }
+
+ /**
+ * Deep-clones a PHP value.
+ *
+ * @template U
+ *
+ * @param U $value
+ *
+ * @return U
+ */
+ public static function deepClone(mixed $value): mixed
+ {
+ return (new self($value))->clone();
+ }
+
+ /**
+ * Returns true when the value doesn't need cloning (scalars, null, enums, scalar-only arrays).
+ */
+ public function isStaticValue(): bool
+ {
+ return !isset($this->prepared);
+ }
+
+ /**
+ * Creates a deep clone of the value.
+ *
+ * @return T
+ */
+ public function clone(): mixed
+ {
+ if (!isset($this->prepared)) {
+ return $this->value;
+ }
+
+ return self::reconstruct($this->prepared, $this->objectMeta, $this->properties, $this->resolve, $this->states, $this->refs, $this->originals ?? []);
+ }
+
+ /**
+ * Creates a deep clone of the root object using a different class.
+ *
+ * The target class must be compatible with the original (typically in the same hierarchy).
+ *
+ * @template U of object
+ *
+ * @param class-string $class
+ *
+ * @return U
+ */
+ public function cloneAs(string $class): object
+ {
+ $prepared = $this->prepared ?? null;
+ $rootId = \is_int($prepared) ? $prepared : ($prepared instanceof Reference && $prepared->id >= 0 ? $prepared->id : null);
+
+ if (null === $rootId) {
+ throw new LogicException('DeepCloner::cloneAs() requires the value to be an object.');
+ }
+
+ $objectMeta = $this->objectMeta;
+ $objectMeta[$rootId][0] = $class;
+
+ return self::reconstruct($prepared, $objectMeta, $this->properties, $this->resolve, $this->states, $this->refs);
+ }
+
+ public function __serialize(): array
+ {
+ if (!isset($this->prepared)) {
+ return ['value' => $this->value];
+ }
+
+ // Deduplicate class names in objectMeta
+ $classes = [];
+ $classMap = [];
+ $objectMeta = [];
+ foreach ($this->objectMeta as $id => [$class, $wakeup]) {
+ if (!isset($classMap[$class])) {
+ $classMap[$class] = \count($classes);
+ $classes[] = $class;
+ }
+ $objectMeta[$id] = 0 !== $wakeup ? [$classMap[$class], $wakeup] : $classMap[$class];
+ }
+
+ // When all entries share class index 0 with wakeup 0, store just the count
+ $n = \count($objectMeta);
+ foreach ($objectMeta as $v) {
+ if (0 !== $v) {
+ $n = $objectMeta;
+ break;
+ }
+ }
+
+ // Replace References in prepared with int ids, tracking positions via mask
+ $mask = null;
+ $prepared = self::replaceRefs($this->prepared, $mask);
+
+ $data = [
+ 'classes' => 1 === \count($classes) ? $classes[0] : $classes,
+ 'objectMeta' => $n,
+ 'prepared' => $prepared,
+ ];
+
+ if ($mask) {
+ $data['mask'] = $mask;
+ }
+
+ // Replace direct References in properties with their int id (using resolve map)
+ $properties = $this->properties ?? [];
+ foreach (($this->resolve ?? []) as $scope => $names) {
+ foreach ($names as $name => $ids) {
+ foreach ($ids as $id) {
+ if ($properties[$scope][$name][$id] instanceof Reference) {
+ $properties[$scope][$name][$id] = $properties[$scope][$name][$id]->id;
+ }
+ }
+ }
+ }
+
+ if ($properties) {
+ $data['properties'] = $properties;
+ }
+ if ($this->resolve ?? []) {
+ $data['resolve'] = $this->resolve;
+ }
+ if ($this->states ?? []) {
+ $data['states'] = $this->states;
+ }
+ if ($this->refs ?? []) {
+ $data['refs'] = $this->refs;
+ }
+
+ return $data;
+ }
+
+ public function __unserialize(array $data): void
+ {
+ if (\array_key_exists('value', $data)) {
+ $this->value = $data['value'];
+
+ return;
+ }
+
+ // Rebuild class names from deduplicated list
+ $classes = $data['classes'];
+ if (!\is_array($classes)) {
+ $classes = [$classes];
+ }
+ $meta = $data['objectMeta'];
+ if (\is_int($meta)) {
+ $objectMeta = array_fill(0, $meta, [$classes[0], 0]);
+ } else {
+ $objectMeta = [];
+ foreach ($meta as $id => $v) {
+ $objectMeta[$id] = \is_array($v) ? [$classes[$v[0]], $v[1]] : [$classes[$v], 0];
+ }
+ }
+
+ $prepared = $data['prepared'];
+ if (isset($data['mask'])) {
+ $prepared = self::restoreRefs($prepared, $data['mask']);
+ }
+ $this->prepared = $prepared;
+ $this->objectMeta = $objectMeta;
+
+ // Restore References in properties using the resolve map
+ $properties = $data['properties'] ?? [];
+ $resolve = $data['resolve'] ?? [];
+ foreach ($resolve as $scope => $names) {
+ foreach ($names as $name => $ids) {
+ foreach ($ids as $id) {
+ if (\is_int($properties[$scope][$name][$id])) {
+ $properties[$scope][$name][$id] = new Reference($properties[$scope][$name][$id]);
+ }
+ }
+ }
+ }
+
+ $this->properties = $properties;
+ $this->resolve = $resolve;
+ $this->states = $data['states'] ?? [];
+ $this->refs = $data['refs'] ?? [];
+ }
+
+ private static function reconstruct($prepared, $objectMeta, $properties, $resolve, $states, $refs, $originals = [])
+ {
+ // Create all object instances
+ $objects = [];
+
+ if ($originals) {
+ // Clone-and-patch: clone originals (COW-shares all scalar properties)
+ foreach ($originals as $id => $v) {
+ $objects[$id] = clone $v;
+ }
+ } else {
+ foreach ($objectMeta as $id => [$class]) {
+ if (':' === ($class[1] ?? null)) {
+ $objects[$id] = unserialize($class);
+ continue;
+ }
+ Registry::$reflectors[$class] ??= Registry::getClassReflector($class);
+
+ if (Registry::$cloneable[$class]) {
+ $objects[$id] = clone Registry::$prototypes[$class];
+ } elseif (Registry::$instantiableWithoutConstructor[$class]) {
+ $objects[$id] = Registry::$reflectors[$class]->newInstanceWithoutConstructor();
+ } elseif (null === Registry::$prototypes[$class]) {
+ throw new NotInstantiableTypeException($class);
+ } elseif (Registry::$reflectors[$class]->implementsInterface('Serializable') && !method_exists($class, '__unserialize')) {
+ $objects[$id] = unserialize('C:'.\strlen($class).':"'.$class.'":0:{}');
+ } else {
+ $objects[$id] = unserialize('O:'.\strlen($class).':"'.$class.'":0:{}');
+ }
+ }
+ }
+
+ // Resolve hard references
+ foreach ($refs as &$ref) {
+ $ref = self::resolve($ref, $objects, $refs);
+ }
+ unset($ref);
+
+ if ($originals) {
+ // Clone-and-patch: only resolve and hydrate object-reference properties
+ foreach ($resolve as $scope => $names) {
+ $scopeProps = [];
+ foreach ($names as $name => $ids) {
+ foreach ($ids as $id) {
+ $scopeProps[$name][$id] = self::resolve($properties[$scope][$name][$id], $objects, $refs);
+ }
+ }
+ (InternalHydrator::$hydrators[$scope] ??= InternalHydrator::getHydrator($scope))($scopeProps, $objects);
+ }
+ } else {
+ // Full hydration: resolve object refs in-place, then hydrate all properties
+ foreach ($resolve as $scope => $names) {
+ foreach ($names as $name => $ids) {
+ foreach ($ids as $id) {
+ $properties[$scope][$name][$id] = self::resolve($properties[$scope][$name][$id], $objects, $refs);
+ }
+ }
+ }
+ foreach ($properties as $scope => $scopeProps) {
+ (InternalHydrator::$hydrators[$scope] ??= InternalHydrator::getHydrator($scope))($scopeProps, $objects);
+ }
+ }
+
+ foreach ($states as $v) {
+ if (\is_array($v)) {
+ $objects[$v[0]]->__unserialize(self::resolve($v[1], $objects, $refs));
+ } else {
+ $objects[$v]->__wakeup();
+ }
+ }
+
+ if (\is_int($prepared)) {
+ return $objects[$prepared];
+ }
+
+ if ($prepared instanceof Reference) {
+ return $prepared->id >= 0 ? $objects[$prepared->id] : ($prepared->count ? $refs[-$prepared->id] : self::resolve($prepared->value, $objects, $refs));
+ }
+
+ return self::resolve($prepared, $objects, $refs);
+ }
+
+ private static function hasReference($value)
+ {
+ foreach ($value as $v) {
+ if ($v instanceof Reference || $v instanceof NamedClosure || \is_array($v) && self::hasReference($v)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static function resolve($value, $objects, $refs)
+ {
+ if ($value instanceof Reference) {
+ if ($value->id >= 0) {
+ return $objects[$value->id];
+ }
+ if (!$value->count) {
+ return self::resolve($value->value, $objects, $refs);
+ }
+
+ return $refs[-$value->id];
+ }
+
+ if ($value instanceof NamedClosure) {
+ $callable = self::resolve($value->callable, $objects, $refs);
+ if ($value->method?->isPublic() ?? true) {
+ return $callable[0] ? $callable[0]->$callable[1](...) : $callable[1](...);
+ }
+
+ return $value->method->getClosure(\is_object($callable[0]) ? $callable[0] : null);
+ }
+
+ if (\is_array($value)) {
+ foreach ($value as $k => $v) {
+ if ($v instanceof Reference || $v instanceof NamedClosure || \is_array($v)) {
+ $value[$k] = self::resolve($v, $objects, $refs);
+ }
+ }
+ }
+
+ return $value;
+ }
+
+ private static function replaceRefs($value, &$mask)
+ {
+ if ($value instanceof Reference) {
+ if ($value->id < 0) {
+ return $value; // Hard ref - serialize natively
+ }
+ $mask = true;
+
+ return $value->id;
+ }
+
+ if (\is_array($value)) {
+ foreach ($value as $k => $v) {
+ if ($v instanceof Reference || \is_array($v)) {
+ $m = null;
+ $value[$k] = self::replaceRefs($v, $m);
+ if (null !== $m) {
+ $mask[$k] = $m;
+ }
+ }
+ }
+ }
+
+ return $value;
+ }
+
+ private static function restoreRefs($value, $mask)
+ {
+ if (true === $mask) {
+ return new Reference($value);
+ }
+
+ if (\is_array($mask)) {
+ foreach ($mask as $k => $m) {
+ $value[$k] = self::restoreRefs($value[$k], $m);
+ }
+ }
+
+ return $value;
+ }
+}
diff --git a/src/Symfony/Component/VarExporter/Internal/Exporter.php b/src/Symfony/Component/VarExporter/Internal/Exporter.php
index eca8b806dc1..39f18ebf909 100644
--- a/src/Symfony/Component/VarExporter/Internal/Exporter.php
+++ b/src/Symfony/Component/VarExporter/Internal/Exporter.php
@@ -20,12 +20,16 @@ use Symfony\Component\VarExporter\Exception\NotInstantiableTypeException;
*/
class Exporter
{
+ private static array $scopeMaps = [];
+ private static array $protos = [];
+ private static array $classInfo = [];
+
/**
* Prepares an array of values for VarExporter.
*
* For performance this method is public and has no type-hints.
*
- * @param array &$values
+ * @param array $values
* @param \SplObjectStorage $objectsPool
* @param array &$refsPool
* @param int &$objectsCount
@@ -87,16 +91,16 @@ class Exporter
$sleep = null;
$proto = Registry::$prototypes[$class];
- if ($reflector->hasMethod('__serialize')) {
- if (!$reflector->getMethod('__serialize')->isPublic()) {
- throw new \Error(\sprintf('Call to %s method "%s::__serialize()".', $reflector->getMethod('__serialize')->isProtected() ? 'protected' : 'private', $class));
+ if (self::$classInfo[$class][2] ??= $reflector->hasMethod('__serialize') ? ($reflector->getMethod('__serialize')->isPublic() ?: $reflector->getMethod('__serialize')) : false) {
+ if (self::$classInfo[$class][2] instanceof \ReflectionMethod) {
+ throw new \Error(\sprintf('Call to %s method "%s::__serialize()".', self::$classInfo[$class][2]->isProtected() ? 'protected' : 'private', $class));
}
if (!\is_array($arrayValue = $value->__serialize())) {
throw new \TypeError($class.'::__serialize() must return an array');
}
- if ($reflector->hasMethod('__unserialize')) {
+ if (self::$classInfo[$class][0] ??= method_exists($class, '__unserialize')) {
$properties = $arrayValue;
goto prepare_value;
}
@@ -122,7 +126,7 @@ class Exporter
$value = new Reference($id);
goto handle_value;
} else {
- if (method_exists($class, '__sleep')) {
+ if (self::$classInfo[$class][3] ??= method_exists($class, '__sleep')) {
if (!\is_array($sleep = $value->__sleep())) {
trigger_error('serialize(): __sleep should return an array only containing the names of instance-variables to serialize', \E_USER_NOTICE);
$value = null;
@@ -134,21 +138,29 @@ class Exporter
$arrayValue = (array) $value;
}
- $proto = (array) $proto;
+ $proto = self::$protos[$class] ??= (array) $proto;
+
+ if (null === $scopeMap = self::$scopeMaps[$class] ?? null) {
+ $scopeMap = [];
+ $parent = $reflector;
+ do {
+ foreach ($parent->getProperties() as $p) {
+ if (!$p->isStatic() && !isset($scopeMap[$p->name])) {
+ $scopeMap[$p->name] = !$p->isPublic() || $p->isProtectedSet() || $p->isPrivateSet() ? $p->class : 'stdClass';
+ }
+ }
+ } while ($parent = $parent->getParentClass());
+ self::$scopeMaps[$class] = $scopeMap;
+ }
foreach ($arrayValue as $name => $v) {
$i = 0;
$n = (string) $name;
if ('' === $n || "\0" !== $n[0]) {
- $parent = $reflector;
- do {
- $p = $parent->hasProperty($n) ? $parent->getProperty($n) : null;
- } while (!$p && $parent = $parent->getParentClass());
-
- $c = $p && (!$p->isPublic() || $p->isProtectedSet() || $p->isPrivateSet()) ? $p->class : 'stdClass';
+ $c = $scopeMap[$n] ?? 'stdClass';
} elseif ('*' === $n[1]) {
$n = substr($n, 3);
- $c = $reflector->getProperty($n)->class;
+ $c = $scopeMap[$n] ?? $reflector->getProperty($n)->class;
} else {
$i = strpos($n, "\0", 2);
$c = substr($n, 1, $i - 1);
@@ -176,15 +188,17 @@ class Exporter
trigger_error(\sprintf('serialize(): "%s" returned as member variable from __sleep() but does not exist', $n), \E_USER_NOTICE);
}
}
- if (method_exists($class, '__unserialize')) {
+ $hasUnserialize = self::$classInfo[$class][0] ??= method_exists($class, '__unserialize');
+ if ($hasUnserialize) {
$properties = $arrayValue;
}
prepare_value:
+ $hasUnserialize ??= self::$classInfo[$class][0] ??= method_exists($class, '__unserialize');
$objectsPool[$value] = [$id = \count($objectsPool)];
$properties = self::prepare($properties, $objectsPool, $refsPool, $objectsCount, $valueIsStatic);
++$objectsCount;
- $objectsPool[$value] = [$id, $class, $properties, method_exists($class, '__unserialize') ? -$objectsCount : (method_exists($class, '__wakeup') ? $objectsCount : 0)];
+ $objectsPool[$value] = [$id, $class, $properties, $hasUnserialize ? -$objectsCount : ((self::$classInfo[$class][1] ??= method_exists($class, '__wakeup')) ? $objectsCount : 0)];
$value = new Reference($id);
diff --git a/src/Symfony/Component/VarExporter/Internal/Hydrator.php b/src/Symfony/Component/VarExporter/Internal/Hydrator.php
index 8f923ec31d4..900c43052fb 100644
--- a/src/Symfony/Component/VarExporter/Internal/Hydrator.php
+++ b/src/Symfony/Component/VarExporter/Internal/Hydrator.php
@@ -92,10 +92,12 @@ class Hydrator
};
}
- if (!class_exists($class) && !interface_exists($class, false) && !trait_exists($class, false)) {
- throw new ClassNotFoundException($class);
+ if (!$classReflector = Registry::$reflectors[$class] ?? null) {
+ if (!class_exists($class) && !interface_exists($class, false) && !trait_exists($class, false)) {
+ throw new ClassNotFoundException($class);
+ }
+ $classReflector = Registry::$reflectors[$class] = new \ReflectionClass($class);
}
- $classReflector = new \ReflectionClass($class);
switch ($class) {
case 'ArrayIterator':
@@ -194,10 +196,12 @@ class Hydrator
};
}
- if (!class_exists($class) && !interface_exists($class, false) && !trait_exists($class, false)) {
- throw new ClassNotFoundException($class);
+ if (!$classReflector = Registry::$reflectors[$class] ?? null) {
+ if (!class_exists($class) && !interface_exists($class, false) && !trait_exists($class, false)) {
+ throw new ClassNotFoundException($class);
+ }
+ $classReflector = Registry::$reflectors[$class] = new \ReflectionClass($class);
}
- $classReflector = new \ReflectionClass($class);
switch ($class) {
case 'ArrayIterator':
@@ -262,7 +266,7 @@ class Hydrator
public static function getPropertyScopes($class): array
{
$propertyScopes = [];
- $r = new \ReflectionClass($class);
+ $r = Registry::$reflectors[$class] ?? new \ReflectionClass($class);
foreach ($r->getProperties() as $property) {
$flags = $property->getModifiers();
diff --git a/src/Symfony/Component/VarExporter/README.md b/src/Symfony/Component/VarExporter/README.md
index e470bbb9955..5685cd21bb7 100644
--- a/src/Symfony/Component/VarExporter/README.md
+++ b/src/Symfony/Component/VarExporter/README.md
@@ -11,6 +11,9 @@ of objects:
- `Instantiator::instantiate()` creates an object and sets its properties without
calling its constructor nor any other methods;
- `Hydrator::hydrate()` can set the properties of an existing object;
+- `DeepCloner` deep-clones PHP values while preserving copy-on-write benefits
+ for strings and arrays, making it faster and more memory efficient than
+ `unserialize(serialize())`;
- `Lazy*Trait` can make a class behave as a lazy-loading ghost or virtual proxy.
VarExporter::export()
@@ -57,6 +60,26 @@ Hydrator::hydrate($object, [], [
]);
```
+DeepCloner
+----------
+
+`DeepCloner::deepClone()` deep-clones a PHP value. Unlike
+`unserialize(serialize())`, it preserves PHP's copy-on-write semantics for
+strings and arrays, resulting in lower memory usage and better performance:
+
+```php
+$clone = DeepCloner::deepClone($originalObject);
+```
+
+For repeated cloning of the same structure, create an instance to amortize the
+cost of graph analysis:
+
+```php
+$cloner = new DeepCloner($prototype);
+$clone1 = $cloner->clone();
+$clone2 = $cloner->clone();
+```
+
Lazy Proxies
------------
diff --git a/src/Symfony/Component/VarExporter/Tests/DeepCloneTest.php b/src/Symfony/Component/VarExporter/Tests/DeepCloneTest.php
new file mode 100644
index 00000000000..06bf6188db4
--- /dev/null
+++ b/src/Symfony/Component/VarExporter/Tests/DeepCloneTest.php
@@ -0,0 +1,428 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\VarExporter\Tests;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\VarExporter\DeepCloner;
+use Symfony\Component\VarExporter\Exception\LogicException;
+use Symfony\Component\VarExporter\Tests\Fixtures\FooReadonly;
+use Symfony\Component\VarExporter\Tests\Fixtures\FooUnitEnum;
+use Symfony\Component\VarExporter\Tests\Fixtures\GoodNight;
+use Symfony\Component\VarExporter\Tests\Fixtures\MyWakeup;
+
+class DeepCloneTest extends TestCase
+{
+ public function testScalars()
+ {
+ $this->assertSame(42, DeepCloner::deepClone(42));
+ $this->assertSame('hello', DeepCloner::deepClone('hello'));
+ $this->assertSame(3.14, DeepCloner::deepClone(3.14));
+ $this->assertTrue(DeepCloner::deepClone(true));
+ $this->assertNull(DeepCloner::deepClone(null));
+ }
+
+ public function testSimpleArray()
+ {
+ $arr = ['a', 'b', [1, 2, 3]];
+ $clone = DeepCloner::deepClone($arr);
+ $this->assertSame($arr, $clone);
+ }
+
+ public function testUnitEnum()
+ {
+ $enum = FooUnitEnum::Bar;
+ $this->assertSame($enum, DeepCloner::deepClone($enum));
+ }
+
+ public function testSimpleObject()
+ {
+ $obj = new \stdClass();
+ $obj->foo = 'bar';
+ $obj->baz = 123;
+
+ $clone = DeepCloner::deepClone($obj);
+
+ $this->assertNotSame($obj, $clone);
+ $this->assertEquals($obj, $clone);
+ $this->assertSame('bar', $clone->foo);
+ $this->assertSame(123, $clone->baz);
+ }
+
+ public function testNestedObjects()
+ {
+ $inner = new \stdClass();
+ $inner->value = 'inner';
+
+ $outer = new \stdClass();
+ $outer->child = $inner;
+ $outer->name = 'outer';
+
+ $clone = DeepCloner::deepClone($outer);
+
+ $this->assertNotSame($outer, $clone);
+ $this->assertNotSame($inner, $clone->child);
+ $this->assertSame('inner', $clone->child->value);
+ $this->assertSame('outer', $clone->name);
+
+ // Mutating original doesn't affect clone
+ $inner->value = 'changed';
+ $this->assertSame('inner', $clone->child->value);
+ }
+
+ public function testCircularReference()
+ {
+ $a = new \stdClass();
+ $b = new \stdClass();
+ $a->ref = $b;
+ $b->ref = $a;
+
+ $clone = DeepCloner::deepClone($a);
+
+ $this->assertNotSame($a, $clone);
+ $this->assertNotSame($b, $clone->ref);
+ $this->assertSame($clone, $clone->ref->ref);
+ }
+
+ public function testArrayWithObjects()
+ {
+ $obj = new \stdClass();
+ $obj->x = 42;
+
+ $arr = ['key' => $obj, 'str' => 'hello'];
+ $clone = DeepCloner::deepClone($arr);
+
+ $this->assertNotSame($obj, $clone['key']);
+ $this->assertSame(42, $clone['key']->x);
+ $this->assertSame('hello', $clone['str']);
+
+ // Mutating original doesn't affect clone
+ $obj->x = 99;
+ $this->assertSame(42, $clone['key']->x);
+ }
+
+ public function testSameObjectMultipleReferences()
+ {
+ $shared = new \stdClass();
+ $shared->val = 'shared';
+
+ $root = new \stdClass();
+ $root->a = $shared;
+ $root->b = $shared;
+
+ $clone = DeepCloner::deepClone($root);
+
+ $this->assertNotSame($shared, $clone->a);
+ $this->assertSame($clone->a, $clone->b);
+ }
+
+ public function testSleepWakeup()
+ {
+ $obj = new MyWakeup();
+ $obj->sub = 123;
+ $obj->bis = 'ignored_by_sleep';
+ $obj->baz = 'baz_value';
+ $obj->def = 456;
+
+ $clone = DeepCloner::deepClone($obj);
+
+ $this->assertNotSame($obj, $clone);
+ // __sleep returns ['sub', 'baz'], so 'bis' and 'def' should be reset
+ $this->assertSame(123, $clone->sub);
+ // __wakeup sets bis=123 and baz=123 when sub===123
+ $this->assertSame(123, $clone->bis);
+ $this->assertSame(123, $clone->baz);
+ // def is not in __sleep, so it gets its default value 234
+ $this->assertSame(234, $clone->def);
+ }
+
+ public function testSerializeUnserialize()
+ {
+ $obj = new class {
+ public string $name = '';
+ public array $data = [];
+
+ public function __serialize(): array
+ {
+ return ['name' => $this->name, 'data' => $this->data];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->name = $data['name'];
+ $this->data = $data['data'];
+ }
+ };
+
+ $obj->name = 'test';
+ $obj->data = ['a', 'b', 'c'];
+
+ $clone = DeepCloner::deepClone($obj);
+
+ $this->assertNotSame($obj, $clone);
+ $this->assertSame('test', $clone->name);
+ $this->assertSame(['a', 'b', 'c'], $clone->data);
+ }
+
+ public function testReadonlyProperties()
+ {
+ $obj = new FooReadonly('hello', 'world');
+
+ $clone = DeepCloner::deepClone($obj);
+
+ $this->assertNotSame($obj, $clone);
+ $this->assertSame('hello', $clone->name);
+ $this->assertSame('world', $clone->value);
+ }
+
+ public function testPrivateAndProtectedProperties()
+ {
+ $obj = new GoodNight();
+ // __construct: unset($this->good), $this->foo='afternoon', $this->bar='morning'
+
+ $clone = DeepCloner::deepClone($obj);
+
+ $this->assertNotSame($obj, $clone);
+ $this->assertEquals($obj, $clone);
+ }
+
+ public function testDateTime()
+ {
+ $dt = new \DateTime('2024-01-15 10:30:00', new \DateTimeZone('UTC'));
+
+ $clone = DeepCloner::deepClone($dt);
+
+ $this->assertNotSame($dt, $clone);
+ $this->assertEquals($dt, $clone);
+
+ // Mutating original doesn't affect clone
+ $dt->modify('+1 day');
+ $this->assertNotEquals($dt, $clone);
+ }
+
+ public function testSplObjectStorage()
+ {
+ $s = new \SplObjectStorage();
+ $o1 = new \stdClass();
+ $o1->id = 1;
+ $o2 = new \stdClass();
+ $o2->id = 2;
+ $s[$o1] = 'info1';
+ $s[$o2] = 'info2';
+
+ $clone = DeepCloner::deepClone($s);
+
+ $this->assertNotSame($s, $clone);
+ $this->assertCount(2, $clone);
+ }
+
+ public function testDeepCloneMatchesSerializeUnserialize()
+ {
+ $inner = new \stdClass();
+ $inner->value = str_repeat('x', 1000);
+
+ $outer = new \stdClass();
+ $outer->child = $inner;
+ $outer->items = ['a', 'b', $inner];
+ $outer->number = 42;
+
+ $cloneA = unserialize(serialize($outer));
+ $cloneB = DeepCloner::deepClone($outer);
+
+ $this->assertEquals($cloneA, $cloneB);
+ }
+
+ public function testNamedClosure()
+ {
+ $fn = strlen(...);
+ $clone = DeepCloner::deepClone($fn);
+
+ $this->assertSame(5, $clone('hello'));
+ }
+
+ public function testRepeatedClones()
+ {
+ $obj = new \stdClass();
+ $obj->value = 'original';
+
+ $cloner = new DeepCloner($obj);
+
+ $clone1 = $cloner->clone();
+ $clone2 = $cloner->clone();
+
+ $this->assertNotSame($obj, $clone1);
+ $this->assertNotSame($obj, $clone2);
+ $this->assertNotSame($clone1, $clone2);
+ $this->assertEquals($obj, $clone1);
+ $this->assertEquals($obj, $clone2);
+
+ $clone1->value = 'changed';
+ $this->assertSame('original', $clone2->value);
+ $this->assertSame('original', $obj->value);
+ }
+
+ public function testRepeatedClonesWithNestedGraph()
+ {
+ $a = new \stdClass();
+ $b = new \stdClass();
+ $a->ref = $b;
+ $b->ref = $a;
+ $a->data = str_repeat('x', 1000);
+
+ $cloner = new DeepCloner($a);
+
+ for ($i = 0; $i < 3; ++$i) {
+ $clone = $cloner->clone();
+ $this->assertNotSame($a, $clone);
+ $this->assertSame($clone, $clone->ref->ref);
+ $this->assertSame(str_repeat('x', 1000), $clone->data);
+ }
+ }
+
+ public function testStaticValues()
+ {
+ $cloner = new DeepCloner(42);
+ $this->assertSame(42, $cloner->clone());
+
+ $cloner = new DeepCloner(['a', 'b']);
+ $this->assertSame(['a', 'b'], $cloner->clone());
+ }
+
+ public function testCloneAs()
+ {
+ $obj = new FooReadonly('hello', 'world');
+
+ $cloner = new DeepCloner($obj);
+ $clone = $cloner->cloneAs(FooReadonly::class);
+
+ $this->assertInstanceOf(FooReadonly::class, $clone);
+ $this->assertNotSame($obj, $clone);
+ $this->assertSame('hello', $clone->name);
+ $this->assertSame('world', $clone->value);
+ }
+
+ public function testCloneAsRequiresObject()
+ {
+ $this->expectException(LogicException::class);
+ $cloner = new DeepCloner([1, 2, new \stdClass()]);
+ $cloner->cloneAs(\stdClass::class);
+ }
+
+ public function testOriginalMutationDoesNotAffectClone()
+ {
+ $obj = new \stdClass();
+ $obj->foo = 'original';
+ $obj->child = new \stdClass();
+ $obj->child->bar = 'inner';
+
+ $cloner = new DeepCloner($obj);
+
+ // Mutate original after creating the cloner
+ $obj->foo = 'mutated';
+ $obj->child->bar = 'mutated-inner';
+
+ $clone = $cloner->clone();
+ $this->assertSame('original', $clone->foo);
+ $this->assertSame('inner', $clone->child->bar);
+
+ // Second clone should also be unaffected
+ $clone2 = $cloner->clone();
+ $this->assertSame('original', $clone2->foo);
+ $this->assertSame('inner', $clone2->child->bar);
+ }
+
+ public function testSerializeClonerStaticValue()
+ {
+ $cloner = new DeepCloner(42);
+ $restored = unserialize(serialize($cloner));
+
+ $this->assertTrue($restored->isStaticValue());
+ $this->assertSame(42, $restored->clone());
+ }
+
+ public function testSerializeClonerWithObjects()
+ {
+ $obj = new \stdClass();
+ $obj->foo = 'bar';
+ $obj->child = new \stdClass();
+ $obj->child->val = 'inner';
+
+ $cloner = new DeepCloner($obj);
+ $data = serialize($cloner);
+
+ // originals should not be in serialized form
+ $this->assertStringNotContainsString('originals', $data);
+
+ $restored = unserialize($data);
+ $this->assertFalse($restored->isStaticValue());
+
+ $clone = $restored->clone();
+ $this->assertSame('bar', $clone->foo);
+ $this->assertSame('inner', $clone->child->val);
+ $this->assertNotSame($clone, $restored->clone());
+ }
+
+ public function testSerializeClonerStripsEmptyProperties()
+ {
+ $obj = new \stdClass();
+ $obj->foo = 'bar';
+
+ $cloner = new DeepCloner($obj);
+ $data = serialize($cloner);
+
+ // No refs, no states, no resolve for this simple case - should be absent
+ $this->assertStringNotContainsString('states', $data);
+ $this->assertStringNotContainsString('refs', $data);
+ }
+
+ public function testSerializeClonerWithCircularRef()
+ {
+ $a = new \stdClass();
+ $b = new \stdClass();
+ $a->ref = $b;
+ $b->ref = $a;
+
+ $cloner = new DeepCloner($a);
+ $restored = unserialize(serialize($cloner));
+
+ $clone = $restored->clone();
+ $this->assertSame($clone, $clone->ref->ref);
+ }
+
+ public function testSerializeClonerWithNullByteKey()
+ {
+ $obj = new \stdClass();
+ $obj->data = ["\0" => 'nul-key', 'normal' => new \stdClass()];
+ $obj->data['normal']->x = 42;
+
+ $cloner = new DeepCloner($obj);
+ $restored = unserialize(serialize($cloner));
+
+ $clone = $restored->clone();
+ $this->assertSame('nul-key', $clone->data["\0"]);
+ $this->assertSame(42, $clone->data['normal']->x);
+ $this->assertNotSame($obj->data['normal'], $clone->data['normal']);
+ }
+
+ public function testIsStaticValue()
+ {
+ $this->assertTrue((new DeepCloner(42))->isStaticValue());
+ $this->assertTrue((new DeepCloner('hello'))->isStaticValue());
+ $this->assertTrue((new DeepCloner(null))->isStaticValue());
+ $this->assertTrue((new DeepCloner(true))->isStaticValue());
+ $this->assertTrue((new DeepCloner([1, 'a', [2]]))->isStaticValue());
+ $this->assertTrue((new DeepCloner([]))->isStaticValue());
+ $this->assertTrue((new DeepCloner(FooUnitEnum::Bar))->isStaticValue());
+
+ $this->assertFalse((new DeepCloner(new \stdClass()))->isStaticValue());
+ $this->assertFalse((new DeepCloner(['key' => new \stdClass()]))->isStaticValue());
+ }
+}
diff --git a/src/Symfony/Component/VarExporter/composer.json b/src/Symfony/Component/VarExporter/composer.json
index a3ced35d5ac..197c6a0c96d 100644
--- a/src/Symfony/Component/VarExporter/composer.json
+++ b/src/Symfony/Component/VarExporter/composer.json
@@ -1,8 +1,8 @@
{
"name": "symfony/var-exporter",
"type": "library",
- "description": "Allows exporting any serializable PHP data structure to plain PHP code",
- "keywords": ["export", "serialize", "instantiate", "hydrate", "construct", "clone", "lazy-loading", "proxy"],
+ "description": "Provides tools to export, instantiate, hydrate, clone and lazy-load PHP objects",
+ "keywords": ["export", "serialize", "instantiate", "hydrate", "construct", "clone", "deep-clone", "lazy-loading", "proxy"],
"homepage": "https://symfony.com",
"license": "MIT",
"authors": [