升級 Laravel 13 後,快取讀出來的資料全變了

升級後 API 回傳 __PHP_Incomplete_Class,追查後發現是 cache.php 的一行新設定,加上快取裡混進了物件。

發生了什麼

升級 Laravel 13 後,原本正常的 API 開始回傳這個:

json
{
  "main_image": {
    "__PHP_Incomplete_Class_Name": "App\\Http\\Resources\\ImageResource",
    "resource": {
      "__PHP_Incomplete_Class_Name": "App\\Models\\Media",
      "incrementing": true,
      "exists": true
    }
  }
}

本地沒事,測試站才會壞。


背景:快取怎麼存資料

Laravel 用 Redis 存快取時,資料會經過 PHP 的 serialize() 轉成字串存進去,讀出來再用 unserialize() 轉回來。如果存的是陣列,轉回來就是陣列。如果存的是物件,轉回來就需要找到對應的 class 才能還原。

序列化的完整說明和跨語言比較,寫在另一篇:為什麼快取只該存資料,不該存物件

Laravel 13 改了什麼

config/cache.php 多了一行:

php
'serializable_classes' => false,

這個值會傳給 unserialize()

php
// Laravel 13 RedisStore 原始碼
unserialize($value, ['allowed_classes' => $this->serializableClasses]);

設為 false 的意思是:讀快取時,禁止還原任何 PHP 物件。快取裡有物件的話,會變成 __PHP_Incomplete_Class

各種設定值

行為
null(Laravel 12) unserialize($value) — 還原所有物件
true unserialize($value, ['allowed_classes' => true]) — 同上
false(Laravel 13 預設) unserialize($value, ['allowed_classes' => false]) — 禁止還原物件
[App\DTO\Stats::class] 只還原白名單內的 class

這個設定只影響讀,不影響存。serialize() 永遠會完整序列化所有東西。


為什麼要這樣改

unserialize() 是一個攻擊面。APP_KEY 洩漏的話,攻擊者可以偽造 Redis 裡的序列化字串,透過 __wakeup()__destruct() 執行任意程式碼。設為 false 可以堵住這條路。

但安全只是表面原因。更根本的問題是:快取裡本來就不該有物件。程式因為這個設定壞了,代表之前存快取的方式一直有問題,只是沒被抓到。


物件怎麼混進快取的

常見的寫法,在 Service 層組裝資料存快取:

php
class ProductService
{
    public function getProducts(): array
    {
        $cached = Cache::get('products');
        if ($cached !== null) {
            return $cached;
        }

        $products = Product::with('coverImage')->get();

        $items = $products->map(fn ($product) => [
            'id'    => $product->id,
            'name'  => $product->name,
            'price' => $product->price,
            'cover' => new ImageResource($product->coverImage), // ⚠️ 物件
        ])->toArray();

        Cache::put('products', $items, 600);

        return $items;
    }
}

問題在 ->toArray()。很多人以為它會把所有東西轉成陣列,但 Collection::toArray() 只處理最外層,裡面的值不動:

php
$collection = collect([
    ['id' => 1, 'cover' => new ImageResource($media)],
]);

$array = $collection->toArray();

// [
//     ['id' => 1, 'cover' => ImageResource{…}],  ← 物件還在
// ]

存進 Redis 的其實是陣列和物件的混合體。Laravel 12 的 unserialize() 默默幫你還原了這些物件,所以沒人發現。Laravel 13 拒絕還原,問題才浮出來。


流程圖

Laravel 快取序列化流程:從 map + toArray 到 unserialize 的三種結果


修法

resolve() 轉成純陣列(推薦)

Resource 數量少的時候,逐一處理最清楚:

php
$items = $products->map(fn ($product) => [
    'id'    => $product->id,
    'name'  => $product->name,
    'price' => $product->price,
    'cover' => $product->coverImage
        ? ImageResource::make($product->coverImage)->resolve()
        : null,
])->toArray();

Cache::put('products', $items, 600);

resolve() 會立刻把 Resource 轉成陣列:

php
ImageResource::make($media)->resolve();
// → ['title' => '...', 'alt' => '...', 'url' => '...']

json_decode(json_encode()) 一次處理

巢狀多層 Resource 的時候,逐一處理太麻煩:

php
$items = $products->map(fn ($product) => [
    'id'    => $product->id,
    'name'  => $product->name,
    'cover' => new ImageResource($product->coverImage),
])->toArray();

$items = json_decode(json_encode($items), true);

Cache::put('products', $items, 600);

JsonResource 實作了 JsonSerializable,所以 json_encode() 會觸發 toArray() 轉換。

比較

resolve() json_decode(json_encode())
效能 只轉需要的欄位 多一次完整 encode + decode
可讀性 意圖明確 像 hack
維護性 每加一個 Resource 都要處理 不管幾層都自動處理

過渡方案:白名單

暫時不想改程式碼的話,可以開白名單:

php
// config/cache.php
'serializable_classes' => [
    App\Http\Resources\ImageResource::class,
    App\Models\Media::class,
],

但每次新增需要快取的物件都得更新這個清單。長期來看不是辦法。


結語

這個問題在 Laravel 12 就存在了,Laravel 13 只是讓它暴露出來。修法是把快取裡的物件換成純陣列,用 resolve()json_decode(json_encode())