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
$_SESSIONthat 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/closureworks by reserializing source code via reflection. Clever โ but closures that close over database handles or file pointers will still fail.

