Привет, Хабр!

Сериализация в PHP‑проектах уже звучит как прям вечная история. Сначала ручной json_encode на массивах из контроллера. Потом JMS Serializer с горой аннотаций. Потом Fractal для трансформации. Потом «а давайте просто DTO и руками маппить». Каждый раз свой подход, своя библиотека и свой набор костылей.

В Symfony есть встроенный компонент Serializer, который покрывает 90% задач. Но его часто обходят стороной, то ли по привычке, то ли из‑за того, что документация показывает только тривиальные примеры. А между тем, Serializer — гибкий и вполне годный инструмент, если понимать его архитектуру.

Разберём, как он устроен.

Архитектура

Serializer делит процесс на две фазы:

  1. Нормализация — объект превращается в массив.

  2. Кодирование — массив превращается в строку (JSON, XML, CSV). При десериализации — зеркально: декодирование (из строки в массив), потом денормализация (из массива в объект).

Сериализация:     Объект → [Normalizer] → массив → [Encoder] → JSON-строка
Десериализация:   JSON-строка → [Decoder] → массив → [Denormalizer] → Объект

Вы можете вмешаться на каждом шаге отдельно. Encoder можно добавить свой, например, для YAML или Protobuf.

Из коробки у нас плюсом идут:

  • Normalizer: ObjectNormalizer (маппинг через геттеры/сеттеры/рефлексию), DateTimeNormalizer, ArrayDenormalizer, UidNormalizer, BackedEnumNormalizer и другие.

  • Encoder: JsonEncoder, XmlEncoder, CsvEncoder.

Базовое использование

use Symfony\Component\Serializer\SerializerInterface;

class UserController extends AbstractController
{
    #[Route('/api/users/{id}', methods: ['GET'])]
    public function show(User $user, SerializerInterface $serializer): JsonResponse
    {
        $json = $serializer->serialize($user, 'json');
        return new JsonResponse($json, json: true);
    }

    #[Route('/api/users', methods: ['POST'])]
    public function create(
        Request $request,
        SerializerInterface $serializer,
    ): JsonResponse {
        $user = $serializer->deserialize(
            $request->getContent(),
            User::class,
            'json'
        );
        // $user — объект User, заполненный из JSON-тела запроса
    }
}

Работает из коробки. Но стоит кинуть в сериализацию ту же Doctrine‑сущность с десятком связей, получите либо рекурсию, либо гигантский JSON, в котором половина базы.

Многие уверенно пишут на Symfony до первого серьёзного разговора про Serializer, DI, Event Dispatcher или внутреннюю механику компонентов. Если хотите понять, где у вас реально уверенная база, а где пока знание «на уровне работало и ладно», начните с короткого вступительного теста.

С 30 марта по 4 апреля это ещё и просто хороший момент его пройти: в честь дня рождения OTUS действует скидка 15% за тестирование. Она суммируется со скидкой по промокоду.

[Пройти тестирование]

Группы: контролируем, какие поля отдаём

Главный инструмент #[Groups]. Размечаете поля атрибутами, указываете группу при сериализации:

use Symfony\Component\Serializer\Attribute\Groups;

class User
{
    #[Groups(['user:list', 'user:detail'])]
    private int $id;

    #[Groups(['user:list', 'user:detail'])]
    private string $name;

    #[Groups(['user:detail'])]
    private string $email;

    #[Groups(['user:detail'])]
    private \DateTimeInterface $createdAt;

    #[Groups(['user:detail'])]
    #[Groups(['post:list'])]  // Можно несколько атрибутов
    private array $posts;

    // Без группы НИКОГДА не попадёт в JSON
    private string $passwordHash;
    
    // Геттеры...
}
// Список пользователей — только id и name
$json = $serializer->serialize($users, 'json', [
    'groups' => 'user:list',
]);
// {"id":1,"name":"Artem"}

// Детальная карточка — id, name, email, createdAt, posts
$json = $serializer->serialize($user, 'json', [
    'groups' => 'user:detail',
]);

// Можно передать несколько групп
$json = $serializer->serialize($user, 'json', [
    'groups' => ['user:list', 'admin:extra'],
]);

passwordHash без группы не попадёт в JSON никогда, как бы ни менялись группы.

Вложенные объекты и циклические ссылки

У User есть Post[], у Post есть User author, классическая циклическая ссылка. Без обработки получим рекурсию.

Способ 1: группы решают большинство случаев. Просто не ставьте группу user:list на поле $author в Post:

class Post
{
    #[Groups(['post:list', 'user:detail'])]
    private int $id;

    #[Groups(['post:list', 'user:detail'])]
    private string $title;

    #[Groups(['post:detail'])]  // НЕ user:detail — разрываем цикл
    private User $author;
}

При сериализации с группой user:detail у пользователя сериализуются посты, но у постов автор уже не сериализуется, его группы не совпали.

Способ 2: #[MaxDepth] — ограничение глубины вложенности:

class User
{
    #[Groups(['user:detail'])]
    #[MaxDepth(1)]
    private array $posts;
}
$json = $serializer->serialize($user, 'json', [
    'groups' => 'user:detail',
    'enable_max_depth' => true,  // Обязательно включить!
]);

На глубине 1 посты сериализуются полностью. На глубине 2 уже нет. enable_max_depth нужно передавать явно, по умолчанию выключено.

Способ 3: circular_reference_handler — обработчик циклов:

$json = $serializer->serialize($user, 'json', [
    'circular_reference_handler' => fn(object $obj) => $obj->getId(),
]);

При обнаружении цикла вместо полного объекта подставится его ID.

Кастомный Normalizer: когда нужна своя логика

Группы и MaxDepth решают структурные вопросы — какие поля отдать и на какую глубину. Но иногда нужно трансформировать значение. Поле avatar хранит относительный путь uploads/avatars/123.jpg, а в API нужен полный URL https://cdn.example.com/uploads/avatars/123.jpg.

use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;

class UserNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
    use NormalizerAwareTrait;

    public function __construct(
        private string $cdnBaseUrl,
    ) {}

    public function normalize(
        mixed $object,
        ?string $format = null,
        array $context = []
    ): array {
        // Флаг в контексте — защита от рекурсии (объясню ниже)
        $context[self::class] = true;

        // Делегируем стандартному нормалайзеру — он разберёт группы, MaxDepth и т.д.
        $data = $this->normalizer->normalize($object, $format, $context);

        // Дополняем своей логикой
        if (isset($data['avatar'])) {
            $data['avatar'] = $this->cdnBaseUrl . '/' . $data['avatar'];
        }

        // Можно добавить вычисляемые поля
        $data['full_name'] = $object->getFirstName() . ' ' . $object->getLastName();

        return $data;
    }

    public function supportsNormalization(
        mixed $data,
        ?string $format = null,
        array $context = []
    ): bool {
        // Проверяем флаг — если уже внутри нашего нормалайзера, не перехватываем
        return $data instanceof User && !isset($context[self::class]);
    }

    public function getSupportedTypes(?string $format): array
    {
        return [User::class => false];  // false = не кешируемый
    }
}

Без приемчика с $context[self::class] произойдёт бесконечная рекурсия: наш нормалайзер вызывает $this->normalizer->normalize(), Serializer снова находит наш нормалайзер как подходящий для User, вызывает его и так до падения. Флаг в контексте разрывает цикл: при повторном вызове supportsNormalization видит флаг и возвращает false, обработка уходит в стандартный ObjectNormalizer.

Нормалайзер зарегистрируется автоматически через autowiring.

Десериализация: заполняем существующий объект

По умолчанию deserialize() создаёт новый объект через конструктор. Но при обновлении (PATCH/PUT) нужно заполнить существующий:

#[Route('/api/users/{id}', methods: ['PATCH'])]
public function update(
    User $user,
    Request $request,
    SerializerInterface $serializer,
    EntityManagerInterface $em,
): JsonResponse {
    $serializer->deserialize(
        $request->getContent(),
        User::class,
        'json',
        [
            'object_to_populate' => $user,
            'groups' => ['user:update'],
        ]
    );

    $em->flush();

    return $this->json($user, context: ['groups' => 'user:detail']);
}

Поля из JSON перезапишут значения в $user. Поля, которых нет в JSON, останутся нетронутыми. Группа user:update ограничит, какие поля вообще можно обновлять, если кто‑то передаст "role": "admin", но поле role не в группе user:update, оно будет проигнорировано.

Name Converter

JSON отдаёт created_at, а в PHP — $createdAt. Не вешайте #[SerializedName('created_at')] на каждое поле. Одна строка в конфиге:

# config/packages/serializer.yaml
framework:
    serializer:
        name_converter: 'serializer.name_converter.camel_case_to_snake_case'

Все поля автоматически: createdAtcreated_at, firstNamefirst_name. Работает в обе стороны и при сериализации, и при десериализации.

Если для отдельного поля нужно другое имя — #[SerializedName] переопределяет конвертер:

#[SerializedName('user_email')]  // Вместо стандартного 'email'
private string $email;

Работа с датами

DateTimeNormalizer обрабатывает \DateTimeInterface автоматически. По умолчанию формат RFC 3339 (2024-01-15T10:30:00+00:00). Можно поменять:

// Глобально — при сериализации
$json = $serializer->serialize($user, 'json', [
    DateTimeNormalizer::FORMAT_KEY => 'd.m.Y H:i',
]);

// Или на конкретном поле
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private \DateTimeInterface $birthDate;

При десериализации Serializer парсит строку обратно в \DateTime или \DateTimeImmutable в зависимости от тайпхинта.

Работа с Enum

PHP 8.1 enums поддерживаются из коробки через BackedEnumNormalizer:

enum PostStatus: string
{
    case Draft     = 'draft';
    case Published = 'published';
    case Archived  = 'archived';
}

class Post
{
    #[Groups(['post:list'])]
    private PostStatus $status;
}

Сериализуется как "status": "published", десериализуется обратно в enum. Если передать невалидное значение — исключение.

Итого

Serializer — это нормализация + кодирование, два независимых шага. #[Groups] для контроля видимости полей. #[MaxDepth] и circular_reference_handler для вложенности. Кастомные Normalizer с трюком $context[self::class] для трансформации. object_to_populate для обновления существующих объектов. Name Converter для snake_case одной строкой. #[Context] для настройки дат и enum в на уровне поля.

15 апреля в 20:00 разберём, как выстроить локализацию в Symfony от простых кейсов до динамических данных из базы — с примерами, которые можно применять в реальных проектах. ☛ [Записаться на открытый урок].

Если вы уже работаете с Symfony и хотите разобраться во фреймворке глубже — не только на уровне типовых решений, но и на уровне внутренней логики и практики реальных проектов, — стоит присмотреться к курсу «Symfony Framework».

А с 1 по 4 апреля сделать этот шаг немного проще: в честь дня рождения OTUS действует дополнительная скидка 10% по промокоду birthday, и она суммируется с другими скидками.

[Получить курс со скидкой]