PHP Fatal error: Serialization of 'Closure' is not allowed の修正方法

intermediate🐘 PHP2026-06-11| Linux、Windows、macOS上のPHP 7.x / 8.x — Laravel、Symfony、またはセッション・Redis/Memcachedキャッシュ・キューを使用するアプリで頻発

Error Message

Fatal error: Uncaught Exception: Serialization of 'Closure' is not allowed
#php#シリアライゼーション#クロージャ#例外

何が起きているのか

コードのどこかで、PHPのクロージャを含むオブジェクトまたは配列がserialize()に渡されています。PHPのシリアライズエンジンが無名関数に当たった瞬間、処理が止まります:

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

よくある原因:

  • クロージャプロパティを持つオブジェクトを$_SESSIONに保存している
  • クロージャを埋め込んだままキュー(Laravel、Symfony Messenger)にジョブをプッシュしている
  • serialize()を使ってオブジェクトをRedisやMemcachedにキャッシュしている
  • クロージャそのもの、またはクロージャを含む配列・オブジェクトに対して直接serialize()を呼んでいる

問題のクロージャを特定する

まずスタックトレースを確認します。原因がフレームワーク内部に埋もれている場合は、簡単な事前チェックで絞り込めます:

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

var_dump(canSerialize($yourObject)); // false = クロージャが含まれている

オブジェクトの階層が深い場合、クロージャは見つけにくい場所に隠れていることがあります。次の再帰スキャナはツリー全体を走査し、問題のある箇所を正確に出力します:

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);
// 出力例: Closure found at: root->formatter

修正方法1 — __sleep()と__wakeup()でクロージャを除外する

クロージャプロパティがシリアライズ後に不要な場合(必要に応じて再生成できる場合)、__sleep()を使ってPHPにスキップするよう伝えます:

class ReportGenerator
{
    private string $name;
    private \Closure $formatter; // これがエラーの原因

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

    // シリアライズしても安全なプロパティ名のみを返す
    public function __sleep(): array
    {
        return ['name'];
    }

    // デシリアライズ後にデフォルトのクロージャを再設定する
    public function __wakeup(): void
    {
        $this->formatter = static fn($v) => $v;
    }
}

unserialize()の後、__wakeup()がクロージャを復元します。デフォルトのフォーマッタや基本的なトランスフォーマーなど、ロジックがステートレスで一から再生成できる場合に最適です。

修正方法2 — クロージャをInvokableクラスに置き換える

クロージャをInvokableクラスに変換するのが最も堅牢な修正方法です。エラーなくシリアライズでき、クロージャと全く同じ呼び出し構文を持ち、ユニットテストも格段に書きやすくなります:

// 修正前: シリアライズを妨げるクロージャ
$formatter = function(string $value): string {
    return strtoupper(trim($value));
};

// 修正後: Invokableクラス — 完全にシリアライズ可能
class UpperTrimFormatter
{
    public function __invoke(string $value): string
    {
        return strtoupper(trim($value));
    }
}

$formatter = new UpperTrimFormatter();

// 呼び出し構文は同じ
echo $formatter('  hello world  '); // "HELLO WORLD"

// エラーなくシリアライズできる
$serialized = serialize($formatter); // 正常動作
$restored   = unserialize($serialized);
echo $restored('  hello world  '); // まだ "HELLO WORLD"

修正方法3 — opis/closureで本当のクロージャシリアライズを実現する

任意のコールバックを保存したり、古いフレームワークでクロージャをジョブとしてディスパッチしたりと、クロージャのシリアライズが本当に必要な場合があります。そのための解決策がopis/closureです:

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

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

// シリアライズ前にラップする
$wrapper    = new SerializableClosure($closure);
$serialized = serialize($wrapper);

// デシリアライズ後にアンラップする
$wrapper  = unserialize($serialized);
$restored = $wrapper->getClosure();

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

Laravelはバージョン5.1からopis/closureを同梱しています。Laravel 7以降では、dispatch()でクロージャをディスパッチすれば自動的にラップされます。古いバージョンや別のフレームワークを使用している場合は、上記のように手動でラップしてください。

修正方法4 — セッションにクロージャを保存しない

PHPは$_SESSIONに書き込まれたすべてのデータをシリアライズします。クロージャプロパティを持つオブジェクトが一つでも混入すると、次のページロード時(PHPがセッションを読み戻す際)にエラーが発生します。セッションには純粋なデータのみを保存し、ロジックはリクエストごとに再構築してください:

// 悪い例 — クロージャがセッションに入ってしまう
$_SESSION['handler'] = function() { return 'result'; };

// 良い例 — キーを保存し、リクエストごとにクロージャを再構築する
$_SESSION['handler_type'] = 'default';

// 各リクエストで保存されたタイプから再構築する(matchはPHP 8.0+が必要;7.xではif/elseを使用)
$handler = match ($_SESSION['handler_type']) {
    'premium' => fn() => 'premium result',
    default   => fn() => 'standard result',
};

修正方法5 — Laravel / Symfony キュージョブ

よくある2つのミス:opis/closureのラッパーを経由せずに素のクロージャをディスパッチすること、そしてジョブのコンストラクタにクロージャを注入することです。どちらもシリアライズを壊します。適切な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);
    }
}

動作確認

修正後、シリアライズとデシリアライズの往復が正常に機能することを確認します:

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";
}

Linuxでセッションの問題が発生している場合、各リクエスト後にセッションファイルが書き込まれているか確認します:

# LinuxにおけるPHPのデフォルトセッションディレクトリ
ls -la /var/lib/php/sessions/

# 更新時刻を監視する — 更新が止まった場合、セッションの書き込みがサイレントに失敗している
watch -n1 ls -la /var/lib/php/sessions/

XAMPPやWAMPを使用したWindowsの場合、セッションファイルはC:\xampp\tmp\、またはお使いのphp.inisession.save_pathで指定された場所にあります。

教訓

  • クロージャは実行コンテキストをキャプチャします。PHPにはそれらのバインディングをバイト列として表現する汎用的な方法がないため、シリアライズは完全にブロックされます。これはバグではなく、意図的な設計上の選択です。
  • オブジェクトをセッション、キャッシュ、またはキューに入れるたびに、クロージャプロパティがないか必ず確認してください。3階層深くに埋もれた無名関数一つで、このエラーが発生します。
  • Invokableクラスが標準的な解決策です:クロージャと同じ呼び出し構文を持ち、完全にシリアライズ可能で、単体テストも容易です。
  • __sleep()はホワイトリストとして考えてください。永続化すべきプロパティを明示的に宣言し、それ以外を安全だと思い込まないでください。
  • opis/closureはリフレクションを通じてソースコードを再シリアライズする仕組みです。巧妙な方法ですが、データベースハンドルやファイルポインタをクローズオーバーしているクロージャは依然として失敗します。

Related Error Notes