From 2dba3dd5910165c77164959c052abaacac53bdbe Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Thu, 19 Mar 2026 15:29:55 +1000 Subject: [PATCH] Fix v2.0.9 regression: nested closure wrapping for objects with __serialize The v2.0.9 change to skip objects with __serialize was too broad - it prevented nested closures (e.g. in array properties) from being wrapped, causing "Serialization of 'Closure' is not allowed" errors in patterns like Bus::chain with closures inside Bus::batch. Instead of skipping the entire object, skip only Closure-typed properties (which the object's __serialize handles) while still recursing into other properties to wrap nested closures. Fixes laravel/serializable-closure#126 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Serializers/Native.php | 41 +++++++++++++++- .../ClassWithSerializeAndNestedClosures.php | 48 +++++++++++++++++++ .../SerializeNestedClosureRegressionTest.php | 44 +++++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 tests/Fixtures/ClassWithSerializeAndNestedClosures.php create mode 100644 tests/SerializeNestedClosureRegressionTest.php 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');