發生了什麼
升級 Laravel 13 後,原本正常的 API 開始回傳這個:
{
"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 多了一行:
'serializable_classes' => false,這個值會傳給 unserialize():
// 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 層組裝資料存快取:
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() 只處理最外層,裡面的值不動:
$collection = collect([
['id' => 1, 'cover' => new ImageResource($media)],
]);
$array = $collection->toArray();
// [
// ['id' => 1, 'cover' => ImageResource{…}], ← 物件還在
// ]
存進 Redis 的其實是陣列和物件的混合體。Laravel 12 的 unserialize() 默默幫你還原了這些物件,所以沒人發現。Laravel 13 拒絕還原,問題才浮出來。
流程圖
修法
用 resolve() 轉成純陣列(推薦)
Resource 數量少的時候,逐一處理最清楚:
$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 轉成陣列:
ImageResource::make($media)->resolve();
// → ['title' => '...', 'alt' => '...', 'url' => '...']
用 json_decode(json_encode()) 一次處理
巢狀多層 Resource 的時候,逐一處理太麻煩:
$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 都要處理 | 不管幾層都自動處理 |
過渡方案:白名單
暫時不想改程式碼的話,可以開白名單:
// config/cache.php
'serializable_classes' => [
App\Http\Resources\ImageResource::class,
App\Models\Media::class,
],但每次新增需要快取的物件都得更新這個清單。長期來看不是辦法。
結語
這個問題在 Laravel 12 就存在了,Laravel 13 只是讓它暴露出來。修法是把快取裡的物件換成純陣列,用 resolve() 或 json_decode(json_encode())。