Skip to content

v2.0.9 regression: objects with __serialize no longer have nested closures wrapped, causing serialization failure #126

@esetnik

Description

@esetnik

Description

v2.0.9 introduced a regression where closures nested inside objects that implement __serialize are no longer wrapped by Native::wrapClosures() and Native::mapByReference(), causing Serialization of 'Closure' is not allowed errors.

This was introduced by #122 which added || $reflection->hasMethod('__serialize') to skip walking object properties when the class implements __serialize. While the original fix correctly addressed the TypeError: Cannot assign Native to property of type Closure issue, it also prevents the library from finding and wrapping closures that live inside those objects (e.g. in array properties or untyped properties).

Reproduction

<?php

require __DIR__ . '/vendor/autoload.php';

use Laravel\SerializableClosure\SerializableClosure;

// Object with __serialize that has closures in an array property
class TaskRunner {
    public string $taskName = 'daily-report';
    /** @var \Closure[] */
    public array $filters = [];

    public function __serialize(): array
    {
        return [
            'taskName' => $this->taskName,
            'filters' => $this->filters,
        ];
    }

    public function __unserialize(array $data): void
    {
        $this->taskName = $data['taskName'];
        $this->filters = $data['filters'];
    }

    public function createTask(): \Closure
    {
        $this->filters[] = function () { return true; };

        return function () {
            return "running {$this->taskName}";
        };
    }
}

$runner = new TaskRunner();
$closure = $runner->createTask();

// PASS on v2.0.8, FAIL on v2.0.9+
$serialized = serialize(new SerializableClosure($closure));
$unserialized = unserialize($serialized);
echo $unserialized->getClosure()();

Expected behavior

Closures nested in objects with __serialize (in array properties, untyped properties, etc.) should still be wrapped with Native instances before serialization, as they were in v2.0.8.

Actual behavior (v2.0.9+)

[Exception]
Serialization of 'Closure' is not allowed

Versions tested

Version Result
v2.0.8 ✅ PASS
v2.0.9 ❌ FAIL
v2.0.10 ❌ FAIL
2.x-dev ❌ FAIL

Tested on PHP 8.5.2.

Root cause

The change in src/Serializers/Native.php (#122):

- if (! $reflection->isUserDefined()) {
+ if (! $reflection->isUserDefined() || $reflection->hasMethod('__serialize')) {
      $storage[$instance] = $data;
      return;
  }

This skips the entire object when it has __serialize, so closures inside it are never replaced with serializable Native wrappers. When PHP later serializes the object via __serialize(), those raw \Closure values pass through and hit PHP's native "Serialization of 'Closure' is not allowed" error.

Suggested fix

Instead of skipping the entire object, call __serialize() to get the data array, then walk that array for closures. This avoids the TypeError on typed Closure properties (the original #122 issue) while still wrapping nested closures:

if ($reflection->hasMethod('__serialize')) {
    $storage[$instance] = $data;
    // Walk the __serialize output for nested closures
    $serializeData = $instance->__serialize();
    static::wrapClosures($serializeData, $storage);
    return;
}

(The exact implementation may need more nuance around re-injecting the wrapped data, but the principle is: don't skip closure discovery just because the object handles its own serialization format.)

Impact

This breaks any library using SerializableClosure where the serialized closure captures or is bound to an object implementing __serialize that contains closures. Known affected: crunzphp/crunz#122.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions