diff --git a/src/Serializers/Native.php b/src/Serializers/Native.php index dbfeb894..d38b1eaa 100644 --- a/src/Serializers/Native.php +++ b/src/Serializers/Native.php @@ -255,7 +255,7 @@ public static function wrapClosures(&$data, $storage) $instance = $data; $reflection = new ReflectionObject($instance); - if (! $reflection->isUserDefined() || $reflection->hasMethod('__serialize')) { + if (! $reflection->isUserDefined()) { $storage[$instance] = $data; return; @@ -279,6 +279,12 @@ public static function wrapClosures(&$data, $storage) $value = $property->getValue($instance); + if (static::isClosureTypedProperty($property)) { + $property->setValue($data, $value); + + continue; + } + if (is_array($value) || is_object($value)) { static::wrapClosures($value, $storage); } @@ -473,7 +479,7 @@ protected function mapByReference(&$data) $reflection = new ReflectionObject($data); - if (! $reflection->isUserDefined() || $reflection->hasMethod('__serialize')) { + if (! $reflection->isUserDefined()) { $this->scope[$instance] = $data; return; @@ -497,6 +503,12 @@ protected function mapByReference(&$data) $value = $property->getValue($instance); + if (static::isClosureTypedProperty($property)) { + $property->setValue($data, $value); + + continue; + } + if (is_array($value) || is_object($value)) { $this->mapByReference($value); } @@ -517,4 +529,29 @@ protected static function isVirtualProperty(ReflectionProperty $property): bool { return method_exists($property, 'isVirtual') && $property->isVirtual(); } + + /** + * Determine if property is typed as Closure. + * + * @param \ReflectionProperty $property + * @return bool + */ + protected static function isClosureTypedProperty(ReflectionProperty $property): bool + { + $type = $property->getType(); + + if ($type instanceof \ReflectionNamedType) { + return $type->getName() === 'Closure'; + } + + if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { + foreach ($type->getTypes() as $t) { + if ($t instanceof \ReflectionNamedType && $t->getName() === 'Closure') { + return true; + } + } + } + + return false; + } } diff --git a/tests/Fixtures/ClassWithSerializeAndNestedClosures.php b/tests/Fixtures/ClassWithSerializeAndNestedClosures.php new file mode 100644 index 00000000..3c2ce483 --- /dev/null +++ b/tests/Fixtures/ClassWithSerializeAndNestedClosures.php @@ -0,0 +1,48 @@ + */ + public array $chainItems; + + public Closure $callback; + + public function __construct(string $name, array $chainItems, Closure $callback) + { + $this->name = $name; + $this->chainItems = $chainItems; + $this->callback = $callback; + } + + public function __serialize(): array + { + return [ + 'name' => $this->name, + 'chainItems' => $this->chainItems, + 'callback' => new SerializableClosure($this->callback), + ]; + } + + public function __unserialize(array $data): void + { + $this->name = $data['name']; + $this->chainItems = $data['chainItems']; + $this->callback = $data['callback']->getClosure(); + } +} diff --git a/tests/SerializeNestedClosureRegressionTest.php b/tests/SerializeNestedClosureRegressionTest.php new file mode 100644 index 00000000..e3d038e9 --- /dev/null +++ b/tests/SerializeNestedClosureRegressionTest.php @@ -0,0 +1,44 @@ + 'step1', 'plain-value', fn () => 'step2'], + fn () => 'callback' + ); + + // Simulate the pattern: a closure captures an object that has __serialize + // and that object's array properties contain closures. + // Before the fix, wrapClosures would skip the entire object, leaving + // the closures in chainItems unwrapped, causing serialization failure. + $closure = function () use ($obj) { + return $obj->name; + }; + + expect(s($closure))->toBeInstanceOf(Closure::class); + expect(s($closure)())->toBe('test-chain'); +})->with('serializers'); + +test('objects with __serialize have array property closures wrapped when bound via $this', function () { + $obj = new ClassWithSerializeAndNestedClosures( + 'bound-chain', + [fn () => 'bound-step'], + fn () => 'bound-callback' + ); + + $closure = Closure::bind(function () { + return $this->name; + }, $obj, ClassWithSerializeAndNestedClosures::class); + + expect(s($closure))->toBeInstanceOf(Closure::class); + expect(s($closure)())->toBe('bound-chain'); +})->with('serializers');