Fix PHP Fatal error: Serialization of 'Closure' is not allowed

intermediate๐Ÿ˜ PHP2026-06-11| PHP 7.x / 8.x on Linux, Windows, macOS โ€” commonly triggered in Laravel, Symfony, or any app using sessions, Redis/Memcached caching, or queues

Error Message

Fatal error: Uncaught Exception: Serialization of 'Closure' is not allowed
#php#serialization#closure#exception

What's happening

Somewhere in your code, an object or array holding a PHP Closure is being passed through serialize(). PHP's serialization engine hits the anonymous function โ€” and stops cold:

Fatal error: Uncaught Exception: Serialization of 'Closure' is not allowed
  in /var/www/html/app/Services/CacheService.php:45
Stack trace:
#0 /var/www/html/app/Services/CacheService.php(45): serialize(Object(ReportGenerator))
#1 {main}
  thrown in /var/www/html/app/Services/CacheService.php on line 45

The most common triggers:

  • Storing an object in $_SESSION that has a closure property
  • Pushing a job to a queue (Laravel, Symfony Messenger) with a closure embedded inside
  • Caching an object in Redis or Memcached using serialize()
  • Calling serialize() directly on a closure, or on an array/object that contains one

Find the closure causing the problem

Start with the stack trace. When the source is buried in framework internals, a quick pre-check narrows it down:

function canSerialize($value): bool {
    try {
        serialize($value);
        return true;
    } catch (\Exception $e) {
        return false;
    }
}

var_dump(canSerialize($yourObject)); // false = has a closure inside

Deep object graphs hide closures easily. This recursive scanner walks the full tree and prints exactly where the problem lives:

function findClosures($value, string $path = 'root'): void {
    if ($value instanceof \Closure) {
        echo "Closure found at: $path\n";
        return;
    }
    if (is_object($value)) {
        foreach ((array) $value as $key => $prop) {
            findClosures($prop, $path . '->' . $key);
        }
    }
    if (is_array($value)) {
        foreach ($value as $key => $item) {
            findClosures($item, $path . '[' . $key . ']');
        }
    }
}

findClosures($yourObject);
// Output example: Closure found at: root->formatter

Fix 1 โ€” Exclude the closure with __sleep() and __wakeup()

When a closure property doesn't need to survive serialization โ€” because you can rebuild it on demand โ€” use __sleep() to tell PHP to skip it:

class ReportGenerator
{
    private string $name;
    private \Closure $formatter; // this causes the error

    public function __construct(string $name, \Closure $formatter)
    {
        $this->name      = $name;
        $this->formatter = $formatter;
    }

    // Return only the property names that are safe to serialize
    public function __sleep(): array
    {
        return ['name'];
    }

    // Re-attach a default closure after deserialization
    public function __wakeup(): void
    {
        $this->formatter = static fn($v) => $v;
    }
}

After unserialize(), __wakeup() puts the closure back. Works best when the logic is stateless enough to recreate from scratch โ€” a default formatter, a basic transformer.

Fix 2 โ€” Replace the closure with an invokable class

Converting the closure to an invokable class is the most durable fix. It serializes without error, calls exactly like a closure, and is far easier to unit test:

// Before: closure that blocks serialization
$formatter = function(string $value): string {
    return strtoupper(trim($value));
};

// After: invokable class โ€” fully serializable
class UpperTrimFormatter
{
    public function __invoke(string $value): string
    {
        return strtoupper(trim($value));
    }
}

$formatter = new UpperTrimFormatter();

// Calling syntax is identical
echo $formatter('  hello world  '); // "HELLO WORLD"

// Serializes without error
$serialized = serialize($formatter); // works
$restored   = unserialize($serialized);
echo $restored('  hello world  '); // still "HELLO WORLD"

Fix 3 โ€” Use opis/closure for true closure serialization

Sometimes you genuinely need closure serialization โ€” storing arbitrary callbacks, or dispatching closures as jobs in an older framework. That's where opis/closure comes in:

composer require opis/closure
use Opis\Closure\SerializableClosure;

$closure = function(string $name): string {
    return "Hello, $name!";
};

// Wrap before serializing
$wrapper    = new SerializableClosure($closure);
$serialized = serialize($wrapper);

// Unwrap after deserializing
$wrapper  = unserialize($serialized);
$restored = $wrapper->getClosure();

echo $restored('World'); // "Hello, World!"

Laravel has bundled opis/closure since version 5.1. From Laravel 7 onward, dispatch a closure via dispatch() and the wrapping is automatic. On an older version or a different framework, wrap manually as shown above.

Fix 4 โ€” Stop storing closures in sessions

PHP serializes everything written to $_SESSION. One object with a closure property sneaks in, and the error surfaces on the next page load โ€” when PHP reads the session back. Store plain data only; rebuild logic per request:

// BAD โ€” closure ends up in session
$_SESSION['handler'] = function() { return 'result'; };

// GOOD โ€” store a key, rebuild the closure per-request
$_SESSION['handler_type'] = 'default';

// Reconstruct from the stored type on each request (match requires PHP 8.0+; use if/else for 7.x)
$handler = match ($_SESSION['handler_type']) {
    'premium' => fn() => 'premium result',
    default   => fn() => 'standard result',
};

Fix 5 โ€” Laravel / Symfony queue jobs

Two common mistakes here: dispatching a bare closure that bypasses the opis/closure wrapper, and injecting a closure into a job's constructor. Both break serialization. Refactor to a proper Job class either way:

// BAD โ€” closure embedded in a job property
class ProcessReport implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public int $reportId,
        public \Closure $postProcess // postProcessType
        $processor = PostProcessorFactory::make($this->postProcessType);
        $processor->run($this->reportId);
    }
}

Verification

After your fix, confirm the round-trip works end-to-end:

try {
    $serialized = serialize($yourObject);
    $restored   = unserialize($serialized);
    echo "Serialization OK โ€” " . strlen($serialized) . " bytes\n";
    var_dump($restored);
} catch (\Exception $e) {
    echo "Still failing: " . $e->getMessage() . "\n";
}

For session issues on Linux, check that the session file is being written after each request:

# Default PHP session directory on Linux
ls -la /var/lib/php/sessions/

# Watch the modification time โ€” if it stops updating, the session write is silently failing
watch -n1 ls -la /var/lib/php/sessions/

On Windows with XAMPP or WAMP, session files are in C:\xampp\tmp\ โ€” or wherever session.save_path points in your php.ini.

Lessons learned

  • Closures capture execution context. PHP has no portable way to represent those bindings as bytes, so serialization is blocked entirely โ€” a deliberate design choice, not a bug.
  • Every time an object goes into a session, cache, or queue, audit it for closure properties. One anonymous function buried three levels deep is enough to blow this up.
  • Invokable classes are the standard answer: same calling syntax as closures, fully serializable, and easy to test in isolation.
  • Think of __sleep() as an allowlist. Declare exactly which properties should persist; don't assume anything else is safe.
  • opis/closure works by reserializing source code via reflection. Clever โ€” but closures that close over database handles or file pointers will still fail.

Related Error Notes