Laravel: сериализация и десериализация
Glossary overview

Laravel: сериализация и десериализация

Сериализация — это превращение объекта из памяти в плоский формат (строку, массив, байты), который можно сохранить или передать куда угодно: в 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, переименовали класс — весь кэш ломается снова.