Symfony Serializer: JSON и PHP-объекты — мастерская и 9 практических примеров
Превращаем JSON в PHP-объекты: девять практических сценариев, настоящий PHP-обработчик, группы полей, даты, вложенные коллекции и Validator. Мастерская и исходники Windows/Linux.

JSON и PHP-объекты без полного фреймворка: DTO, группы, даты, коллекции, ошибки и Validator. Настоящая PHP-мастерская, девять практических сценариев и исходники для Windows и Linux/VPS.
Symfony Serializer: зачем превращать JSON в PHP-объекты
Представьте обработчик заявки: браузер прислал имя, почту, дату встречи и список услуг. В начале проекта хватает json_decode(), нескольких проверок и обращения к элементам массива. Затем появляются мобильный клиент, импорт старых записей, другой формат названий полей и публичный ответ без внутренних комментариев. Код преобразования начинает повторяться в контроллерах, консольных командах и интеграциях.
Symfony Serializer помогает задать понятный переход между внешними данными и PHP-объектами. В этой мастерской JSON превращается в небольшие DTO, а объекты возвращаются в JSON с выбранными полями и форматом даты. DTO означает объект передачи данных: здесь он хранит структуру запроса, не обращается в базу и не отправляет письма. Поэтому преобразование можно запускать отдельно и проверять на коротких примерах.
Мы соберём проект без полного Symfony Framework, разберём девять практических сценариев и дадим настоящий PHP-обработчик. На странице можно менять JSON, получать ошибки конкретных полей, переключать настройки и скачивать результат вместе с готовым рецептом. Ниже есть путь от первого запуска в Windows до отдельного сайта с PHP-FPM на Linux/VPS.
Где применять: API, импорт, формы, очереди и интеграции
Первое применение — входной контракт API. Клиент отправляет документ, обработчик создаёт CreateOrderInput или Signup, затем отдельный сервис проверяет права и выполняет действие. Код сервиса получает известные свойства, а не произвольный массив. При изменении формата проще найти точку преобразования и увидеть, какое поле стало обязательным.
Второе применение — импорт данных. Выгрузку поставщика можно читать построчно, превращать каждую запись в DTO и сохранять ошибки вместе с номером строки. Тот же подход работает в фоновой очереди: сообщение из JSON становится небольшим объектом команды. При этом важны версия сообщения и совместимость старых задач с новым кодом — Serializer не принимает это решение за разработчика.
Третье применение — ответы приложения. Для списка клиентов нужны идентификатор и имя; для карточки менеджера добавляются контакты; для интеграции требуется snake_case. В мастерской эти ситуации разделены, чтобы вы сначала проверили одно правило, а затем составили собственный контракт. Библиотека особенно полезна, когда одинаковые правила преобразования нужны сразу в HTTP, CLI и фоновых задачах.
Чем Serializer отличается от json_encode и json_decode
Стандартные функции PHP остаются хорошим выбором для простого массива настроек или небольшого ответа. Они кодируют JSON и декодируют его в базовые структуры. Когда формат уже совпадает с массивом приложения, дополнительный слой может быть лишним. Важно оценить объём правил, а не заменять две стандартные функции только ради зависимости.
| Задача | Стандартный JSON | В этой мастерской |
|---|---|---|
| Прочитать документ | Массив или stdClass | Заранее выбранный DTO |
| Вложенные элементы | Обработка вручную | Address и Item по описанию типов |
| Выбрать поля ответа | Собрать массив | Атрибуты Groups |
| Дата и часовой пояс | Подготовить строку | DateTimeNormalizer и явный контекст |
| Правила формы | Отдельные проверки | Отдельный Validator после преобразования |
Serializer полезен тем, что превращает повторяющиеся правила в конфигурацию и классы. Но стоимость тоже есть: зависимости, метаданные, изучение исключений и дополнительные проверки контракта. Для одной строки JSON это необязательно. Для нескольких связанных API такой слой делает преобразование более последовательным и уменьшает количество случайных array_map и ручного копирования полей.
$array = json_decode($json, true, 16, JSON_THROW_ON_ERROR);
// Здесь тип результата — массив. Customer сам не появится.
$response = json_encode($array, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
Мини-мастерская: настоящий PHP, девять задач и исходники
Выберите задачу, прочитайте подсказку и измените входной JSON. Кнопка «Выполнить PHP» отправит данные ограниченному обработчику сайта. Он создаст один из заранее определённых классов и вернёт фактический результат Symfony Serializer. Браузер показывает этап, на котором остановилась обработка: чтение JSON, создание DTO или проверка правил. Вводить своё имя класса или исполняемый PHP здесь нельзя.
Для первого знакомства откройте «Отсутствие, null и false». Удалите поле note, верните его со значением null, затем замените null на пустую строку. Во втором опыте сравните public и admin. В опыте типов сначала уберите лишнее поле legacy, а затем посмотрите на две ошибки типов. Такая последовательность показывает причины результата и не сводит мастерскую к кнопке с заранее приготовленным ответом.
Выполнение отправляет содержимое редактора на сервер: используйте учебные значения, а не реальные контакты, документы или секреты. Обработчик не сохраняет данные в базу или файлы. Если PHP временно недоступен, отдельная кнопка открывает запись настоящего запуска исходного примера. Запись заметно подписана, не пересчитывает изменённый JSON и воспроизводится командой composer verify.
Версии, зависимости и установка через Composer
Проект закрепляет Symfony Serializer 7.4.23 и связанные компоненты PropertyAccess, PropertyInfo и Validator той же версии. Ветка 7.4 имеет длительную поддержку и требует PHP 8.2 или новее; актуальный статус указан на официальной странице релиза. Lock-файл сохраняет весь разрешённый набор зависимостей, поэтому для готового архива используйте install, а не случайное обновление пакетов.
composer install --no-interaction --no-plugins --no-scripts
composer check-platform-reqs
composer test
composer verify
Если собираете новый отдельный проект, зависимости можно добавить командой ниже. phpdocumentor/reflection-docblock нужен нашему примеру коллекций: он помогает прочитать Item[] из PHPDoc. Для только плоского Customer набор можно упростить. Готовая мастерская уже содержит composer.json и composer.lock, автозагрузку и фикстуры, поэтому повторять require поверх скачанного проекта не требуется.
composer require symfony/serializer:7.4.23 symfony/property-access:7.4.23 symfony/property-info:7.4.23 symfony/validator:7.4.23 phpdocumentor/reflection-docblock:^5.6
Подробности установки, lock-файла, автозагрузки и команд разобраны в нашей статье Composer: практическое руководство. На сервере не обходите проверку требований через ignore-platform-reqs: это лишь скрывает несовместимость, а исполняться PHP-код всё равно будет в реальной среде.
Как устроен путь JSON → данные → DTO → ответ
В проекте есть четыре полезных слова. Decode читает JSON в структуру PHP. Denormalize создаёт из структуры объект нужного класса. Normalize превращает объект в структуру для передачи. Encode записывает структуру в JSON. Методы deserialize и serialize объединяют соответствующие пары, поэтому повседневный код обычно остаётся коротким.
Это разделение помогает при диагностике. Если потеряна кавычка, до DTO выполнение не дошло. Если вместо количества передана строка с текстом, документ прочитан, но свойство не подходит по типу. Если возраст равен семнадцати, объект создать можно, однако правило регистрации может запретить такой запрос. Эти три ситуации должны давать разные объяснения, иначе посетитель будет исправлять не то поле.
$dto = $serializer->deserialize($json, Customer::class, 'json', $inputContext);
$array = $serializer->normalize($dto, 'json', $outputContext);
$jsonResponse = $serializer->serialize($dto, 'json', $outputContext);
Название класса в реальном API выбирает сервер. Не принимайте class из произвольного клиентского параметра: допустимые DTO отражают сценарии приложения. В нашей обвязке есть фиксированная таблица из девяти идентификаторов; пользователь меняет только данные и несколько разрешённых настроек.
Собираем Serializer без полного фреймворка
В полном Symfony сервисы часто настраиваются автоматически. В самостоятельном проекте зависимости соединяем явно в одном фабричном методе. Это полезно и для понимания: видно, какой компонент читает атрибуты, какой определяет типы и кто обрабатывает даты. Если поведение меняется после обновления, искать настройку можно в одном файле.
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory;
use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader;
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
$metadata = new ClassMetadataFactory(new AttributeLoader());
$types = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]);
$serializer = new Serializer([
new DateTimeNormalizer(),
new ArrayDenormalizer(),
new ObjectNormalizer($metadata, null, null, $types),
], [new JsonEncoder()]);
Порядок здесь осмысленный: специальная обработка даты идёт до общего ObjectNormalizer. ArrayDenormalizer нужен для коллекций. AttributeLoader читает Groups и MaxDepth, а PropertyInfo соединяет PHPDoc с реальными типами свойств и конструктора. Фабрика Workshop::serializer() в архиве делает именно это; дополнительный флаг подключает конвертер названий для второго примера.
Конструкторы и границы normalizer/encoder описаны в документации Symfony Serializer. Остальные результаты ниже можно проверить на исходниках и реальном выполнении нашего проекта.
Пример 1. Карточка клиента: JSON и значения по умолчанию
Начнём с небольшого Customer. У него два обязательных строковых поля и флаг active со значением true по умолчанию. Такой DTO подходит для учебного создания клиента: структура достаточно простая, чтобы увидеть работу конструктора без дополнительных деталей. Введите новое имя, отключите active, затем удалите active целиком и сравните ответы.
final class Customer
{
public function __construct(
public string $name,
public string $email,
public bool $active = true,
) {}
}
{
"name": "Анна",
"email": "demo@example.test",
"active": false
}
В нашем запуске явное false сохраняется, а отсутствие active приводит к значению true из конструктора. Это разные запросы. Если удалить name, строгий входной контекст сообщит об обязательном параметре. Если передать email как число, ошибка возникнет раньше проверки адреса: сначала требуется строка, затем уже можно обсуждать её смысл.
DTO в этом примере не доказывает, что почта существует или пользователь имеет право создавать клиентов. Он фиксирует форму данных. Реальное действие нужно выполнять после преобразования, проверки правил и авторизации. Такой порядок позволяет переиспользовать класс в HTTP и CLI, не смешивая его с транспортом.
Пример 2. snake_case снаружи, camelCase внутри PHP
Внешний сервис присылает first_name и postal_code, а в PHP принято использовать firstName и postalCode. Можно вручную переложить поля, но при десятках похожих DTO такой код начинает размножаться. Во втором опыте конвертер названий включён только для Contact, чтобы изменение правил не повлияло на остальные сценарии.
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
$normalizer = new ObjectNormalizer(
$metadata,
new CamelCaseToSnakeCaseNameConverter(),
null,
$types,
);
{
"first_name": "Илья",
"postal_code": "101000",
"send_news": true
}
После преобразования PHP-объект содержит firstName, postalCode и sendNews. При обратной записи наружу снова выходят snake_case. Индекс оставлен строкой: для идентификаторов и кодов важны ведущие нули, а арифметика обычно не нужна. Аналогично стоит заранее решить судьбу номера телефона, артикула и внешнего ID, вместо автоматического превращения всего похожего на число в int.
Конвертер имён не исправляет ошибочную семантику. Если внешний API называет client_code то, что у вас customerId, понадобится явное правило или отдельный адаптер. Сначала зафиксируйте внешний контракт, затем настройте преобразование и добавьте пример, который показывает обратный путь.
Пример 3. Группы полей: публичный и служебный ответ
В карточке профиля есть имя, идентификатор, почта и внутренний комментарий. В публичном списке нужны первые два поля; в служебном учебном ответе — все четыре. Здесь удобны атрибуты Groups, расположенные рядом со свойствами. Они делают правило видимым при чтении класса, а не прячут его в нескольких контроллерах.
use Symfony\Component\Serializer\Attribute\Groups;
final class Profile
{
public function __construct(
#[Groups(['public', 'admin'])] public int $id,
#[Groups(['public', 'admin'])] public string $name,
#[Groups(['admin'])] public string $email,
#[Groups(['admin'])] public string $internalNote = '',
) {}
}
$json = $serializer->serialize($profile, 'json', ['groups' => ['public']]);
Переключите группу в мастерской: исходный DTO остаётся тем же, а набор полей ответа изменяется. Ввод в этом опыте специально не фильтруется группой, чтобы вы наблюдали именно выходной контракт. Для реальной регистрации и редактирования лучше создать отдельные входные DTO, а не принимать все свойства модели и рассчитывать, что интерфейс пришлёт только нужные.
Группа admin — название набора полей, а не проверка администратора. На рабочем сервере её выбирают после проверки доступа. Наш переключатель безопасен только потому, что оперирует вашими учебными значениями и не читает чужие записи. Группы не должны становиться способом передать выбор прав клиенту.
Пример 4. Даты: один момент времени, разные часовые пояса
Дата встречи приходит строкой, а приложение должно сравнивать моменты времени, сортировать события и показывать их в нужном поясе. В Meeting свойство startsAt имеет тип DateTimeImmutable. DateTimeNormalizer создаёт объект, а при ответе использует заданный формат. Введите 2026-11-10T15:30:00+03:00 и переключите UTC на Europe/Moscow.
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
$context = [
DateTimeNormalizer::FORMAT_KEY => 'Y-m-d\TH:i:sP',
DateTimeNormalizer::TIMEZONE_KEY => 'UTC',
];
$response = $serializer->serialize($meeting, 'json', $context);
В UTC пример показывает 12:30 с нулевым смещением; в Москве — 15:30 с +03:00. Timestamp в диагностике остаётся тем же. Это удобная проверка: изменение оформления не должно случайно переносить встречу на три часа. Теперь испортите строку даты и посмотрите, на каком этапе появится ошибка.
В этой мастерской до normalizer проверяется конкретный контракт: существующая календарная дата, время до секунд и явный пояс ±HH:MM или Z. Значение 2026-02-31 отклоняется, а не превращается в март; 29 февраля принимается для високосного 2028 года и отклоняется для 2026. Относительные даты, доли секунд и время без пояса здесь запрещены. Это правило нашего сценария: стандартный разбор DateTime допускает более широкий ввод. День рождения и момент отправки письма требуют разных договорённостей о времени.
Пример 5. Заказ: вложенный объект и коллекция позиций
Реальные запросы редко заканчиваются плоской карточкой. Заказ содержит адрес и несколько позиций; важно получить Address и Item, а не массивы, которые дальше придётся распознавать вручную. В проекте тип адреса указан напрямую, а тип элементов массива — через PHPDoc. Для чтения этого описания мы и добавили PhpDocExtractor.
final class Order
{
public string $id;
public Address $address;
/** @var Item[] */
public array $items;
/** @param Item[] $items */
public function __construct(string $id, Address $address, array $items)
{
$this->id = $id;
$this->address = $address;
$this->items = $items;
}
}
{
"id": "ORD-1042",
"address": {"city": "Казань", "street": "Учебная, 10"},
"items": [
{"sku": "HOST-01", "quantity": 2, "price": "1490.00"},
{"sku": "SUPPORT", "quantity": 1, "price": "500.00"}
]
}
В результате мастерская отдельно показывает фактические имена классов адреса и каждой позиции. Это помогает отличить настоящую денормализацию от простого отражения входного JSON. Замените quantity во второй позиции на текст: ошибка укажет путь внутри коллекции. Для интерфейса заказа такой путь можно связать с конкретной строкой таблицы.
Конструктор Order требует id, address и items: пустой объект или заказ без адреса получает понятную ошибку 422, а не доходит до чтения неинициализированного свойства. При этом пустой список позиций допускается по типу. Правила «хотя бы одна позиция» и «количество положительное» задаются отдельно через Validator. Цена намеренно хранится строкой, но здесь не выполняется денежная арифметика и не проверяется формат суммы. Обязательность конструкции и полноценность бизнес-документа — разные уровни проверки.
Пример 6. Отсутствие поля, null, false и пустая строка
Эти четыре состояния часто ошибочно сливают через empty() или оператор с запасным значением. В форме настроек отсутствие поля может означать «не менять», null — «очистить», false — «выключить», а пустая строка — введённое пустое значение. До написания обработчика нужно договориться, какие из этих смыслов действительно поддерживает ваш API.
final class Preferences
{
public function __construct(
public string $name,
public ?string $note,
public bool $active = false,
) {}
}
$context = [
\Symfony\Component\Serializer\Normalizer\AbstractNormalizer::REQUIRE_ALL_PROPERTIES => true,
];
В нашем опыте note допускает null, но не имеет значения по умолчанию. С включённым REQUIRE_ALL_PROPERTIES отсутствие note считается ошибкой; явный null принимается. Флаг active имеет default false, поэтому его можно не присылать. Переключатель «Пропускать null» влияет только на формирование ответа: он не превращает неполный вход в допустимый запрос.
Для PATCH обычно нужен отдельный объект изменений, который сохраняет информацию о присутствии поля. Нельзя сначала заполнить все отсутствующие значения null, а затем пытаться восстановить намерение клиента. Диагностика inputKeys в мастерской специально оставляет этот след видимым. После нормализации повторно проверьте, не потерял ли ваш интерфейс различие между пустым и отсутствующим.
Пример 7. Лишние поля и ошибки типов
Строгий контракт помогает быстро обнаружить опечатку, устаревший клиент или неожиданное изменение интеграции. В исходном примере strict есть поле legacy, которого нет в Customer. Сначала сервер возвращает EXTRA_FIELDS. Удалите legacy и повторите запуск: теперь становятся видны несовпадения типов name и email. Эти этапы не обязаны объединяться в один универсальный список ошибок.
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
$context = [
AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false,
AbstractNormalizer::REQUIRE_ALL_PROPERTIES => true,
DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true,
];
Сбор ошибок полезен для формы: человеку удобнее исправить несколько полей за один подход. Однако исключение PartialDenormalizationException может содержать частично построенные данные. В мастерской они не становятся успешным результатом и не уходят в сохранение. В реальном обработчике поступайте так же: исправить контракт и повторить операцию безопаснее, чем молча принять половину документа.
Режим совместимости также возможен, но должен быть осознанным. Если ваш публичный API обещает игнорировать неизвестные поля, это отдельное правило версионирования. Не меняйте ALLOW_EXTRA_ATTRIBUTES только ради исчезновения ошибки, пока не ясно, кто присылает поле и какую информацию вы потеряете.
Пример 8. Циклические связи и глубина вложенности
У команды есть участник, а у участника — ссылка на ту же команду. Это естественный граф PHP-объектов, но бесконечно раскрывать его в JSON невозможно. В режиме «Цикл → ссылка с ID» мастерская строит именно такую связь. При повторном встреченном объекте handler возвращает короткую ссылку с типом и идентификатором, сохраняя понятную связь между сущностями.
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
$context = [
AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER =>
static fn(object $object): array => [
'refType' => (new ReflectionClass($object))->getShortName(),
'id' => $object->id,
],
];
Режим «Дерево → MaxDepth» показывает другую задачу: цикл отсутствует, но структура слишком глубока для ответа. В TreeNode свойство child помечено MaxDepth(1), а при нормализации включён ENABLE_MAX_DEPTH. Поэтому третий учебный уровень не раскрывается. Ограничение глубины и обработка циклов решают разные проблемы; сравните оба результата на одинаковых названиях узлов.
use Symfony\Component\Serializer\Attribute\MaxDepth;
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
final class TreeNode
{
public function __construct(public string $name) {}
#[MaxDepth(1)]
public ?TreeNode $child = null;
}
$context = [AbstractObjectNormalizer::ENABLE_MAX_DEPTH => true];
Для API часто ещё проще заранее собрать отдельный DTO ответа: teamId вместо полного Team, список участников без обратной ссылки. Тогда объём документа понятен до сериализации, а изменение ORM-модели не расширяет внешний контракт случайным образом.
Пример 9. Serializer и Validator: структура отдельно от правил
Последний сценарий создаёт Signup, затем запускает Symfony Validator. Строка broken подходит свойству email по типу, но не является допустимым адресом по правилу Email. Число 17 успешно попадает в int age, однако нарушает выбранное для учебной регистрации ограничение 18–120. Этот пример показывает, почему успешная десериализация ещё не означает допустимость операции.
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validation;
final class Signup
{
public function __construct(
#[Assert\NotBlank] #[Assert\Length(min: 2, max: 60)] public string $name,
#[Assert\Email] #[Assert\NotBlank] public string $email,
#[Assert\Range(min: 18, max: 120)] public int $age,
#[Assert\IsTrue] public bool $consent = false,
) {}
}
$validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
$violations = $validator->validate($signup);
Исходный набор в мастерской даёт четыре нарушения. Исправьте имя, почту, возраст и согласие — результат станет успешным. Затем замените age на текст: выполнение остановится раньше, на этапе создания DTO. Так можно проверить, что интерфейс умеет показывать и ошибки структуры, и содержательные замечания к полям, не путая их между собой.
Validator тоже не отправляет запрос в базу за вас: уникальность адреса, доступ к организации, лимит тарифа и доступность слота требуют кода приложения. Больше практических ограничений и проверок форм есть в нашей статье Symfony Validator без фреймворка.
Как превратить исключения в понятный ответ API
Не отдавайте пользователю сырой стек исключения. Он плохо объясняет действие, раскрывает детали приложения и меняется вместе с библиотекой. В Endpoint проекта есть свой небольшой договор: ok, stage, errors и path. Клиент может подсветить поле по path, а сообщение оставить человеку. Код ошибки полезен для автоматических проверок и локализации интерфейса.
{
"ok": false,
"stage": "denormalize",
"errors": [
{
"code": "TYPE_MISMATCH",
"path": "name",
"expected": ["string"],
"actual": "int",
"message": "Значение не соответствует типу свойства."
}
]
}
В нашем учебном протоколе 400 означает ошибку конверта запроса, 422 — ошибку данных выбранного примера, 413 — превышение размера, 415 — неверный Content-Type. Неожиданный серверный сбой получает 500 с общим сообщением. Это договор мастерской, а не обязательный набор статусов для каждого API: важно применять выбранную схему последовательно.
Один адаптер для отсутствующего обязательного параметра учитывает поведение закреплённой версии 7.4.23 при сборе ошибок. Поэтому обновление зависимости должно сопровождаться тестом именно этого случая. Нельзя считать текст сообщения вечным API. Типы исключений обрабатываем явно, а наружу выдаём собственные стабильные коды и безопасные сведения.
Подключение к HTTP-обработчику и форме
В публичной мастерской input передаётся строкой внутри небольшого JSON-конверта. Это позволяет различить ошибку транспорта и ошибку редактируемого документа. Поле scenario выбирается из фиксированного списка, options содержит только известные настройки. Если вы строите собственный API заказа, двойной конверт необязателен: можно читать JSON заказа напрямую и выбирать класс по маршруту.
const response = await fetch('api.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'omit',
body: JSON.stringify({
scenario: 'roundtrip',
input: JSON.stringify({name: 'Анна', email: 'demo@example.test', active: true}),
options: {}
})
});
const result = await response.json();
// Проверяем и HTTP-статус, и result.ok, затем показываем поля ошибки.
В интерфейсе не оставляйте старый успешный ответ рядом с уже изменёнными данными. Здесь любое редактирование сбрасывает результат и отключает его скачивание. При новом запросе предыдущий результат теряет актуальность; запоздавший ответ не перезаписывает новую попытку. Это маленькая деталь, которая помогает человеку понимать, какое действие он действительно проверил.
Кнопка отмены останавливает ожидание в браузере. Она не обещает прервать PHP, который мог уже завершиться. Для долгих операций используйте очередь и отдельный статус задачи; сериализация небольшого DTO должна оставаться короткой подготовкой данных, а не контейнером для длительного импорта.
Ограничения публичной мастерской и работа с данными
Учебный endpoint разрешает только заранее определённые DTO и не принимает путь файла, внешний URL, имя произвольного класса или код. Тело запроса ограничено 32 КиБ, редактируемый JSON — 16 КиБ. В структуре допускается до тридцати элементов коллекции, восьми уровней и двухсот пятидесяти узлов; длина отдельных строк и ключей также ограничена. Это ограничения нашего стенда, а не пределы Symfony Serializer.
Такие рамки помогают держать демонстрацию предсказуемой и объяснимой. Они не заменяют ограничения частоты запросов на сервере. Встроенного распределённого rate limiter в проекте нет: для публичного VPS ниже показан Nginx limit_req. Таймаут PHP также нужно дополнить настройками FPM и reverse proxy, потому что прерывание одного слоя не гарантирует остановку всех остальных.
Вход не записывается приложением в постоянное хранилище. При этом веб-сервер может вести обычные журналы обращений. В своём сервисе заранее определите, что попадает в журнал ошибки; для диагностики достаточно идентификатора запроса, этапа и кодов полей. Полный документ с контактами или секретами обычно не нужен. Подход к журналированию разобран в материале Monolog в PHP.
Windows: запуск проекта и скачанного PHP-рецепта
Распакуйте исходники в отдельную папку, откройте PowerShell и убедитесь, что PHP и Composer доступны через PATH. PHP должен быть версии 8.2 или новее. Затем восстановите зависимости по lock-файлу, проверьте требования и запустите тесты. Готовые HTML, CSS и JavaScript уже находятся в public; Node.js нужен только для пересборки и клиентских проверок.
php --version
composer --version
composer install --no-interaction --no-plugins --no-scripts
composer check-platform-reqs
composer test
composer verify
php bin/run.php nested fixtures/nested.json
php -S 127.0.0.1:8191 -t public
Откройте http://127.0.0.1:8191/ в браузере. Команда занимает текущий терминал, остановка выполняется Ctrl+C. Это встроенный сервер разработки, который слушает только локальный адрес. Для публикации нужен нормальный веб-сервер с PHP-FPM; не переносите команду с флагом -S в постоянную службу открытого сервера.
В мастерской можно скачать PHP-рецепт с текущим входом и настройками. Положите файл в корень проекта рядом с vendor и выполните php recipe.php. Данные внутри закодированы как base64 JSON, поэтому кавычки и переносы из редактора не превращаются в исполняемый PHP. Для правок клиентской части используйте npm.cmd ci –ignore-scripts, npm.cmd run build и npm.cmd test.
Linux: консольный импорт, проверки и коды завершения
Для Ubuntu 24.04 пример использует PHP 8.3 из стандартного репозитория. В другом дистрибутиве названия пакетов и доступные версии могут отличаться: сначала проверьте php –version. Composer устанавливайте по официальной инструкции с проверкой подписи установщика. У приложения нет собственной базы, поэтому для первого опыта не нужны MySQL, Redis и полный набор сервисов.
sudo apt update
sudo apt install php8.3-cli php8.3-mbstring php8.3-xml unzip
composer install --no-interaction --no-plugins --no-scripts
composer check-platform-reqs
composer test
composer verify
php bin/run.php groups fixtures/groups.json '{"group":"admin"}'
php bin/run.php relations fixtures/relations.json '{"relationMode":"depth"}'
CLI полезен для импорта, задач cron и проверки интеграции без браузера. У нашей команды код 0 означает успешный сценарий, 2 — ожидаемую ошибку входных данных, 1 — проблему запуска. Например, исходный validate намеренно содержит неверные значения и завершается кодом 2. Планировщик должен различать такой результат и технический сбой, иначе хорошие диагностические сведения потеряются в общем сообщении «команда упала».
Для большого импорта читайте записи порциями и сохраняйте номер ошибочной строки. Не передавайте целый многогигабайтный документ в этот учебный endpoint: его ограничения созданы для маленьких опытов. Сам Serializer можно встроить в ваш поток обработки, но стратегию памяти, транзакций и повторов определяет приложение.
Linux/VPS: размещение, PHP-FPM и границы document root
На отдельном сервере расположите проект в /var/www/serializer, а document root направьте строго в public. Тогда src, vendor, lock-файлы и тесты остаются вне публичного дерева. Процессу FPM достаточно читать файлы: стенду не нужен каталог для загрузок или постоянной записи. Владельцем проекта может быть пользователь деплоя, а веб-сервер получает минимальные необходимые права.
sudo apt install php8.3-fpm nginx
cd /var/www/serializer
composer install --no-dev --no-interaction --prefer-dist --no-plugins --no-scripts
composer check-platform-reqs
sudo systemctl enable --now php8.3-fpm nginx
Проверьте фактический путь сокета FPM и пользователя веб-сервера. В примерах ниже используется /run/php/php8.3-fpm.sock, но переносить его без проверки не следует. Если CLI запускается, а HTTP выдаёт ошибку, сравните версии и расширения именно CLI и FPM: это разные процессы и иногда разные конфигурации PHP.
В пуле FPM задайте request_terminate_timeout, ограничение памяти и число одновременно работающих процессов по ресурсам VPS. В README приведены ориентиры для небольшого стенда, а не универсальная настройка любого сервера. Перед открытием доступа проверьте health endpoint, успешный запрос, ожидаемую ошибку и недоступность файлов вне public.
Nginx: небольшой API, размер тела и ограничение частоты
Этот фрагмент обслуживает статику и единственный PHP endpoint. Остальные PHP-пути закрыты. Зона limit_req задаётся в контексте http, а ограничение применяется только к api.php. Код 429 позволяет клиенту отличить слишком частые запросы от неправильного JSON. Для отдельной учебной мастерской такая схема проще, чем запускать произвольные PHP-файлы из публичной папки.
# Контекст http, например /etc/nginx/conf.d/serializer-limits.conf
limit_req_zone $binary_remote_addr zone=serializer_api:10m rate=2r/s;
server {
listen 80;
server_name serializer.example.test;
root /var/www/serializer/public;
index index.html;
client_max_body_size 32k;
location / { try_files $uri $uri/ =404; }
location = /api.php {
limit_req zone=serializer_api burst=5 nodelay;
limit_req_status 429;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/api.php;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_read_timeout 5s;
}
location ~ \.php$ { return 404; }
location ~ /\. { deny all; }
}
Домен example.test здесь служит обозначением локального стенда. Перед публичным использованием замените его своим и настройте HTTPS. Выполните nginx -t до reload, затем проверьте ответы через curl. Если перед Nginx стоит другой прокси, настройте доверенные адреса real IP: иначе все посетители могут оказаться под одним общим лимитом.
Подробный разбор повседневной эксплуатации есть в нашей статье Nginx: proxy, кеш и диагностика; синтаксис лимита описан в документации limit_req.
Caddy: HTTPS и передача единственного endpoint в PHP-FPM
Если Caddy уже используется на сервере, отдельный Nginx ради мастерской не обязателен. Направьте домен на VPS, обеспечьте доступ к портам 80/443 и настройте доступ пользователя caddy к сокету FPM. Document root остаётся тем же. Ниже обработка API отделена от статики через взаимоисключающие handle, поэтому запрет остальных PHP-путей не перехватывает разрешённый api.php.
serializer.example.com {
root * /var/www/serializer/public
request_body {
max_size 32KiB
}
@api path /api.php
handle @api {
php_fastcgi unix//run/php/php8.3-fpm.sock
}
handle {
@php path *.php
respond @php 404
file_server
}
}
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -i https://serializer.example.com/api.php
Ограничение размера тела не ограничивает число запросов. У стандартной поставки Caddy нет встроенного аналога указанного Nginx limit_req; для публичной нагрузки потребуется отдельное решение на прокси/CDN или проверенный дополнительный модуль. Не добавляйте в Caddyfile чужую директиву, пока не убедитесь, что соответствующий модуль установлен.
Параметры php_fastcgi и request_body описаны в официальной документации. Практика доменов, HTTPS и проксирования есть в нашей статье Caddy для PHP и Node.js.
Как встроить DTO в существующее приложение
Начните с одной операции: например, принятия заявки на консультацию. Запишите входные поля, обязательность, допустимые типы, отношение к лишним данным и состав ответа. Затем создайте входной DTO, настройте преобразование и свяжите ошибки с полями формы. Только после успешной проверки вызывайте прикладной сервис, который выполняет действие.
Не сериализуйте большую ORM-сущность целиком только потому, что она уже существует. У неё могут быть ленивые связи, служебные свойства и методы, которые неожиданно обращаются в базу. Отдельный DTO ответа делает нагрузку и внешний контракт заметнее. Для списка обычно нужен короткий набор полей, а для карточки — другой, заранее выбранный набор.
Сохранение в базу должно происходить после всей проверки документа. Если запись состоит из шапки и позиций, используйте подходящую транзакцию на уровне хранилища. Serializer сам не отвечает за откат, уникальность, повторную отправку и идемпотентность. Эти задачи относятся к действию приложения; преобразование лишь подготавливает понятный набор данных для него.
// Схема прикладного обработчика:
$input = $serializer->deserialize($body, CreateRequestInput::class, 'json', $context);
$errors = $validator->validate($input);
if (count($errors) > 0) {
// Преобразовать нарушения в ответ 422; действие не выполнять.
} else {
// Проверить права и передать DTO сервису приложения.
// Конкретный сервис и транзакция зависят от вашей задачи.
}
Проверки, обновление зависимостей и воспроизводимость
В архиве есть 55 PHP-проверок: типы и значения по умолчанию, группы, строгие календарные даты, високосные годы, обязательные поля вложенного заказа, отсутствие и null, циклы, глубина, Validator и HTTP-конверт. Отдельные четыре проверки JavaScript проверяют размер в UTF-8, формирование безопасного PHP-рецепта, актуальность ответа и разрешённые настройки. Это проверки поведения проекта, а не только наличия файлов.
composer test
composer verify
composer audit
npm ci --ignore-scripts
npm run build
npm test
npm audit --audit-level=low
Команда verify повторяет девять настоящих PHP-запусков и сравнивает результат с records.json. При изменении правила сначала разберитесь в отличии, затем осознанно обновите запись через composer build. Простая перезапись файла ради зелёной проверки уничтожает смысл эталона. Если вы меняете библиотеку, отдельно проверьте исключения, даты и обязательные nullable-поля.
Аудит зависимостей сообщает о известных на момент проверки проблемах, но не доказывает безопасность всего приложения. Браузерная приёмка также отдельна: нужно пройти успешный путь, ошибку, смену примера, скачивание и узкий экран. Ваш рабочий сервер дополнительно требует проверки FPM, HTTPS, лимитов и поведения под реальной нагрузкой.
Типичные ошибки при первом внедрении
Первая ошибка — ожидать, что тип string проверит адрес почты, а int автоматически задаст положительное количество. Вторая — принять частично созданный объект после ошибки и продолжить сохранение. Третья — включить служебную группу по параметру клиента. Все три приводят к тому, что красиво оформленный DTO начинают считать доказательством корректности и прав, которыми он не является.
Другие частые проблемы связаны с потерей смысла: отсутствие поля заменяют null, false исчезает после empty(), дату без пояса интерпретируют по настройке сервера, а индекс превращают в число. Поэтому полезны маленькие граничные примеры. Добавьте их в тесты до переноса на сервер, чтобы будущий рефакторинг не возвращал уже разобранную ошибку.
Наконец, не смешивайте запись опыта с живым выполнением. На этой странице режимы названы отдельно: работающий PHP вычисляет результат по вашим значениям, а запись показывает только исходную фикстуру. В собственных документациях используйте такое же честное разделение. Человек должен понимать, что он сейчас проверил и какой код сможет запустить у себя.
Скачать проект и продолжить работу
Полный архив содержит DTO, настоящий PHP endpoint, девять фикстур, записи выполнения, CLI, клиентскую мастерскую, тесты, lock-файлы и инструкции Windows/Linux. Собственный код распространяется по MIT; полные тексты лицензий зависимостей приложены. Vendor и node_modules восстанавливаются пакетными менеджерами, чтобы исходники оставались удобными для изучения и обновления.
После знакомства выберите один реальный контракт своего проекта. Опишите обычными словами, какие данные обязательны, что означает отсутствие поля, кто выбирает поля ответа и какие ошибки должен увидеть пользователь. Затем перенесите это в небольшой DTO и проверяемые сценарии. Если вопрос о поведении нельзя объяснить на коротком примере, сначала уточните правило, а уже потом усложняйте нормализатор.


