Chuyện gì đang xảy ra
Ở đâu đó trong code của bạn, một object hoặc array chứa PHP Closure đang được truyền qua serialize(). Engine serialization của PHP gặp anonymous function — và dừng lại ngay lập tức:
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
Các nguyên nhân phổ biến nhất:
- Lưu một object vào
$_SESSIONkhi object đó có property là closure - Đẩy một job vào queue (Laravel, Symfony Messenger) với closure được nhúng bên trong
- Cache một object vào Redis hoặc Memcached bằng
serialize() - Gọi
serialize()trực tiếp lên một closure, hoặc lên array/object có chứa closure
Tìm closure đang gây ra vấn đề
Bắt đầu từ stack trace. Khi nguồn gốc bị chôn vùi sâu trong phần nội bộ của framework, một đoạn kiểm tra nhanh sẽ thu hẹp phạm vi tìm kiếm:
function canSerialize($value): bool {
try {
serialize($value);
return true;
} catch (\Exception $e) {
return false;
}
}
var_dump(canSerialize($yourObject)); // false = có closure bên trong
Các object graph phức tạp có thể che giấu closure rất dễ dàng. Bộ scanner đệ quy này duyệt toàn bộ cây và in ra chính xác nơi vấn đề xuất hiện:
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);
// Ví dụ kết quả: Closure found at: root->formatter
Cách sửa 1 — Loại trừ closure bằng __sleep() và __wakeup()
Khi một property là closure không cần tồn tại sau quá trình serialization — vì bạn có thể tạo lại nó khi cần — hãy dùng __sleep() để yêu cầu PHP bỏ qua nó:
class ReportGenerator
{
private string $name;
private \Closure $formatter; // đây là nguyên nhân gây lỗi
public function __construct(string $name, \Closure $formatter)
{
$this->name = $name;
$this->formatter = $formatter;
}
// Chỉ trả về tên các property an toàn để serialize
public function __sleep(): array
{
return ['name'];
}
// Gắn lại closure mặc định sau khi deserialization
public function __wakeup(): void
{
$this->formatter = static fn($v) => $v;
}
}
Sau khi gọi unserialize(), __wakeup() sẽ đặt closure trở lại. Cách này phù hợp nhất khi logic đủ đơn giản để tạo lại từ đầu — một formatter mặc định, một transformer cơ bản.
Cách sửa 2 — Thay closure bằng invokable class
Chuyển đổi closure thành invokable class là cách sửa bền vững nhất. Nó serialize mà không có lỗi, gọi giống hệt closure, và dễ unit test hơn nhiều:
// Trước: closure chặn serialization
$formatter = function(string $value): string {
return strtoupper(trim($value));
};
// Sau: invokable class — có thể serialize hoàn toàn
class UpperTrimFormatter
{
public function __invoke(string $value): string
{
return strtoupper(trim($value));
}
}
$formatter = new UpperTrimFormatter();
// Cú pháp gọi hoàn toàn giống nhau
echo $formatter(' hello world '); // "HELLO WORLD"
// Serialize mà không có lỗi
$serialized = serialize($formatter); // hoạt động bình thường
$restored = unserialize($serialized);
echo $restored(' hello world '); // vẫn là "HELLO WORLD"
Cách sửa 3 — Dùng opis/closure để serialize closure thực sự
Đôi khi bạn thực sự cần serialize closure — lưu các callback tùy ý, hoặc dispatch closure như job trong framework cũ. Đó là lúc opis/closure phát huy tác dụng:
composer require opis/closure
use Opis\Closure\SerializableClosure;
$closure = function(string $name): string {
return "Hello, $name!";
};
// Bọc lại trước khi serialize
$wrapper = new SerializableClosure($closure);
$serialized = serialize($wrapper);
// Mở ra sau khi deserialize
$wrapper = unserialize($serialized);
$restored = $wrapper->getClosure();
echo $restored('World'); // "Hello, World!"
Laravel đã tích hợp sẵn opis/closure từ phiên bản 5.1. Từ Laravel 7 trở đi, dispatch closure qua dispatch() và việc bọc sẽ được thực hiện tự động. Với phiên bản cũ hơn hoặc framework khác, hãy bọc thủ công như hướng dẫn ở trên.
Cách sửa 4 — Ngừng lưu closure trong session
PHP serialize mọi thứ được ghi vào $_SESSION. Chỉ cần một object có property là closure lọt vào, lỗi sẽ xuất hiện ở lần tải trang tiếp theo — khi PHP đọc lại session. Chỉ lưu dữ liệu thuần túy; tái tạo logic theo từng request:
// SAI — closure bị lưu vào session
$_SESSION['handler'] = function() { return 'result'; };
// ĐÚNG — lưu một key, tái tạo closure cho mỗi request
$_SESSION['handler_type'] = 'default';
// Tạo lại từ kiểu đã lưu trong mỗi request (match yêu cầu PHP 8.0+; dùng if/else cho 7.x)
$handler = match ($_SESSION['handler_type']) {
'premium' => fn() => 'premium result',
default => fn() => 'standard result',
};
Cách sửa 5 — Queue job trong Laravel / Symfony
Có hai lỗi phổ biến ở đây: dispatch một closure trần bỏ qua wrapper của opis/closure, và inject closure vào constructor của job. Cả hai đều phá vỡ serialization. Hãy tái cấu trúc thành một Job class đúng chuẩn trong cả hai trường hợp:
// SAI — closure được nhúng vào property của job
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);
}
}
Kiểm tra lại
Sau khi sửa, hãy xác nhận quá trình serialize/deserialize hoạt động đầy đủ từ đầu đến cuối:
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";
}
Với các vấn đề session trên Linux, hãy kiểm tra xem file session có được ghi sau mỗi request hay không:
# Thư mục session mặc định của PHP trên Linux
ls -la /var/lib/php/sessions/
# Theo dõi thời gian sửa đổi — nếu ngừng cập nhật, nghĩa là ghi session đang thất bại âm thầm
watch -n1 ls -la /var/lib/php/sessions/
Trên Windows với XAMPP hoặc WAMP, file session nằm trong C:\xampp\tmp\ — hoặc bất cứ nơi nào mà session.save_path trỏ đến trong php.ini của bạn.
Bài học rút ra
- Closure lưu giữ execution context. PHP không có cách di động nào để biểu diễn các binding đó dưới dạng bytes, nên serialization bị chặn hoàn toàn — đây là quyết định thiết kế có chủ ý, không phải bug.
- Mỗi khi một object đi vào session, cache hay queue, hãy kiểm tra kỹ các property là closure. Chỉ một anonymous function ẩn sâu ba tầng là đủ để phá vỡ mọi thứ.
- Invokable class là câu trả lời chuẩn: cú pháp gọi giống hệt closure, có thể serialize hoàn toàn, và dễ test độc lập.
- Hãy coi
__sleep()như một allowlist. Khai báo chính xác những property nào cần tồn tại; đừng giả định bất cứ thứ gì khác là an toàn. opis/closurehoạt động bằng cách serialize lại source code qua reflection. Thông minh — nhưng các closure bắt giữ database handle hoặc file pointer vẫn sẽ thất bại.

