Привет, Хабр!
Сериализация в PHP‑проектах уже звучит как прям вечная история. Сначала ручной json_encode на массивах из контроллера. Потом JMS Serializer с горой аннотаций. Потом Fractal для трансформации. Потом «а давайте просто DTO и руками маппить». Каждый раз свой подход, своя библиотека и свой набор костылей.
В Symfony есть встроенный компонент Serializer, который покрывает 90% задач. Но его часто обходят стороной, то ли по привычке, то ли из‑за того, что документация показывает только тривиальные примеры. А между тем, Serializer — гибкий и вполне годный инструмент, если понимать его архитектуру.
Разберём, как он устроен.
Архитектура
Serializer делит процесс на две фазы:
Нормализация — объект превращается в массив.
Кодирование — массив превращается в строку (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'
Все поля автоматически: createdAt ↔ created_at, firstName ↔ first_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, и она суммируется с другими скидками.
