Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions src/Serializers/Native.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
Expand All @@ -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;
}
}
48 changes: 48 additions & 0 deletions tests/Fixtures/ClassWithSerializeAndNestedClosures.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace Tests\Fixtures;

use Closure;
use Laravel\SerializableClosure\SerializableClosure;

/**
* Simulates a class like Laravel's ChainedBatch that has __serialize
* but also contains non-Closure properties with nested closures
* (e.g., an array of chain items that may include closures).
*
* The __serialize method handles the Closure-typed property itself,
* but the array property containing closures relies on the library
* to wrap them before serialization.
*/
class ClassWithSerializeAndNestedClosures
{
public string $name;

/** @var array<int, mixed> */
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();
}
}
44 changes: 44 additions & 0 deletions tests/SerializeNestedClosureRegressionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

use Tests\Fixtures\ClassWithSerializeAndNestedClosures;

/**
* Regression test for https://github.com/laravel/serializable-closure/issues/126
*
* In v2.0.9, objects implementing __serialize no longer have nested closures
* wrapped, causing "Serialization of 'Closure' is not allowed" errors.
* This specifically breaks Bus::chain with closures when Bus::batch is nested inside.
*/
test('objects with __serialize still have nested closures wrapped in outer closure', function () {
$obj = new ClassWithSerializeAndNestedClosures(
'test-chain',
[fn () => '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');
Loading