Сериализация — это превращение объекта из памяти в плоский формат (строку, массив, байты), который можно сохранить или передать куда угодно: в Redis, файл, базу данных, по сети.
Десериализация — обратный процесс: восстановление объекта из этого плоского формата обратно в память.
Зачем это нужно
PHP-объект живёт только в оперативной памяти одного процесса. Redis, файлы и сеть умеют работать только со строками и байтами. Чтобы объект мог «пережить» один HTTP-запрос и быть доступен в следующем — его нужно сериализовать.
Форматы сериализации
Самые распространённые варианты в PHP:
// JSON — читаемый, но теряет PHP-типы (всё становится array/string/int)
json_encode(['name' => 'Anna', 'age' => 25]);
// → '{"name":"Anna","age":25}'
// PHP serialize() — сохраняет классы, типы и вложенные объекты
serialize($user);
// → 'O:4:"User":2:{s:4:"name";s:4:"Anna";s:3:"age";i:25;}'
Laravel при сохранении в Redis по умолчанию использует serialize() / unserialize().
Проблема с Eloquent-объектами
PHP serialize() записывает в строку имя класса и все его свойства. Когда Redis возвращает эту строку и PHP вызывает unserialize() — он пытается воссоздать объект того же класса. Если в объекте были PHP 8.1+ enum-поля (например ModelProfileStatus::Activated), PHP пытается восстановить и их тоже. При малейшем несоответствии возникает ошибка:
// Вместо нормального объекта Redis возвращает это:
__PHP_Incomplete_Class Object
(
[__PHP_Incomplete_Class_Name] => App\Enums\ModelProfileStatus
)
Причины могут быть разные: изменился namespace класса, enum переименовали, добавили новый case — и все закешированные данные становятся мусором.
Это известный PHP-баг #18997 — backed enums имеют нестандартное поведение при serialize()/unserialize(). В некоторых конфигурациях PHP возвращает false вместо enum при десериализации, поэтому Eloquent-модели с enum-кастами ломаются при чтении из Redis.
Что рекомендуют
1. Хранить массивы, не объекты — подход dehydrate / hydrate
Самый надёжный способ — вообще не сериализовать PHP-объекты. Перед сохранением в Redis вручную разобрать модели до плоских массивов (dehydrate), а при чтении собрать их обратно (hydrate). PHP-сериализация объектов при этом не используется вовсе.
// dehydrate — сохраняем только сырые атрибуты, без PHP-объектов
private function dehydrate(LengthAwarePaginator $paginator): array
{
return [
'items' => $paginator->getCollection()->map(fn (ModelProfile $p) => [
'profile' => $p->getAttributes(), // plain array
'city' => $p->city?->getAttributes(), // plain array
'photos' => $p->photos->map->getAttributes()->all(),
'promotions' => $p->activePromotions->map(fn ($promo) => array_merge(
$promo->getAttributes(),
['promotion_status' => $promo->promotionStatus?->getAttributes()],
))->all(),
])->all(),
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'path' => $paginator->path() ?? '/',
];
}
// hydrate — вручную воссоздаём Eloquent-модели из массивов, 0 SQL-запросов
private function hydrate(array $data): LengthAwarePaginator
{
$items = collect($data['items'])->map(function (array $item): ModelProfile {
$profile = (new ModelProfile)->setRawAttributes($item['profile'], true);
if ($item['city']) {
$profile->setRelation('city', (new City)->setRawAttributes($item['city'], true));
}
$profile->setRelation('photos', collect($item['photos'])->map(
fn ($a) => (new ModelPhoto)->setRawAttributes($a, true),
));
$profile->setRelation('activePromotions', collect($item['promotions'])->map(
function (array $a): ModelProfilePromotion {
$promo = (new ModelProfilePromotion)->setRawAttributes(
Arr::except($a, ['promotion_status']), true
);
if ($a['promotion_status']) {
$promo->setRelation(
'promotionStatus',
(new PromotionStatus)->setRawAttributes($a['promotion_status'], true)
);
}
return $promo;
}
));
return $profile;
});
return new ConcretePaginator(
items: $items,
total: $data['total'],
perPage: $data['per_page'],
currentPage: $data['current_page'],
options: ['path' => $data['path']],
);
}
getAttributes() возвращает сырые значения из БД — строки и числа. Никаких enum-объектов, никаких вложенных классов. Redis хранит это как обычный JSON.
setRawAttributes($attrs, true) записывает значения напрямую в модель, минуя cast’ы и мутаторы — именно поэтому при hydrate не нужны SQL-запросы.
Мы такой подход использовали для Кеширования.
app/Services/Home/HomeVipCacheService.php
class HomeVipCacheService
{
use HydratesPaginatorCache;
private const TAG = 'home.vip';
private const TTL_SECONDS = 660;
/**
* On cache hit: restore models from raw attributes — 0 SQL queries.
* On cache miss: run callback, dehydrate to plain arrays, store in Redis.
*/
public function remember(int $page, int $seed, callable $callback): LengthAwarePaginator
{
$key = $this->key($page, $seed);
$cached = Cache::tags(self::TAG)->get($key);
if ($cached !== null) {
return $this->hydrate($cached);
}
/** @var LengthAwarePaginator $paginator */
$paginator = $callback();
Cache::tags(self::TAG)->put($key, $this->dehydrate($paginator), self::TTL_SECONDS);
return $paginator;
}
Теперь можем использовать метод remember для получения из кеша либо для записи в кеш:
class HomePageService {
...
private function resolveModelProfiles(
?int $countryId,
?int $cityId,
array $filters,
int $perPage,
bool $vipOnly,
): LengthAwarePaginator {
$seed = (int) floor(time() / 600);
$page = request()->integer('page', 1);
$hasFilters = collect($filters)->contains(fn ($v) => filled($v) || (is_array($v) && count($v) > 0));
// VIP-only home page (no country/city, no filters): use the dedicated VIP cache.
if ($vipOnly && $countryId === null && $cityId === null && ! $hasFilters) {
return $this->vipCache->remember($page, $seed, fn () => $this->homePageRepository->paginateActiveModelProfiles(
countryId: $countryId,
cityId: $cityId,
filters: $filters,
perPage: $perPage,
vipOnly: $vipOnly,
));
}
// All other catalog pages (country, city, filtered): use the catalog cache.
return $this->catalogCache->remember(
countryId: $countryId,
cityId: $cityId,
filters: $filters,
page: $page,
seed: $seed,
callback: fn () => $this->homePageRepository->paginateActiveModelProfiles(
countryId: $countryId,
cityId: $cityId,
filters: $filters,
perPage: $perPage,
vipOnly: $vipOnly,
),
);
}
2. Laravel 13.5.0+ — хук handleUnserializableClassUsing
Начиная с Laravel 13.5.0 появился специальный хук, который перехватывает момент, когда unserialize() вернул __PHP_Incomplete_Class. Можно задать fallback-поведение:
// AppServiceProvider::boot()
Cache::handleUnserializableClassUsing(function ($value) {
// value — это __PHP_Incomplete_Class объект
// можно вернуть null → Cache::get() вернёт null и уйдёт в cache miss
return null;
});
Это хорошая страховка: вместо фатальной ошибки приложение просто промахнётся мимо кэша и переспросит базу. Однако это реактивный подход — проблема уже возникла, хук лишь смягчает последствия. Подход с dehydrate/hydrate надёжнее, потому что PHP-сериализация объектов не используется вовсе и проблема не возникает в принципе.
3. Список разрешённых классов при unserialize()
PHP позволяет ограничить, какие классы разрешено восстанавливать из сериализованной строки. Остальные вернут __PHP_Incomplete_Class вместо ошибки:
$data = unserialize($cached, [
'allowed_classes' => [
ModelProfile::class,
ModelProfileStatus::class,
City::class,
// ... все используемые классы
],
]);
Подход работает, но хрупкий: при каждом рефакторинге нужно не забыть обновить список. Добавили новый enum, переименовали класс — весь кэш ломается снова.