API в Symfony: типичные ошибки, которые ломают продакшен
Serializer в контроллере, отсутствие версионирования, N+1 в API-эндпоинтах и стектрейсы наружу — разбор частых ошибок при разработке REST API на Symfony 7.x и способов их исправить.

API на Symfony падает не на этапе разработки, а под нагрузкой и в интеграции. Локально всё работает: контроллер отдаёт JSON, тесты зелёные. А в проде — 500 с полным стектрейсом в теле ответа, N+1 запросов на списочном эндпоинте и клиент, который сломался после мелкого рефакторинга сущности. Ниже — ошибки, которые встречаются в API-проектах на Symfony 7.x чаще всего, и что с ними делать. Речь про REST-эндпоинты, собранные вручную на контроллерах и Serializer, а не про API Platform — там свои подводные камни, это отдельная тема.
Ошибка 1. Сериализация сущности напрямую в ответ
Самый частый анти-паттерн: контроллер берёт Doctrine-сущность и отдаёт её через $this->json($user) или напрямую в Serializer. Работает ровно до первого из трёх событий: в сущность добавили поле с паролем, в ответе всплыла ленивая связь и потянула за собой пол-базы, или клиент начал зависеть от имени приватного поля.
Сущность — это модель хранения. Ответ API — это контракт с клиентом. Смешивать их нельзя: любое изменение схемы БД мгновенно протекает наружу.
Если клиент видит структуру вашей таблицы, вы больше не можете менять таблицу, не сломав клиента. Контракт API и схема БД должны эволюционировать независимо.
Решение — отдельный DTO для выдачи. Его можно собрать вручную или через #[Groups] на сущности, но DTO надёжнее: он явно перечисляет, что уходит наружу.
// src/Dto/UserResponse.php
// Symfony 7.x, PHP 8.2+
final readonly class UserResponse
{
public function __construct(
public int $id,
public string $email,
public string $displayName,
public string $createdAt, // ISO 8601 строкой, а не DateTime
) {}
public static function fromEntity(User $user): self
{
return new self(
id: $user->getId(),
email: $user->getEmail(),
displayName: $user->getDisplayName(),
createdAt: $user->getCreatedAt()->format(\DateTimeInterface::ATOM),
);
}
}
Теперь новое поле в сущности не утечёт в ответ само собой — вы добавите его в DTO осознанно.
Ошибка 2. Стектрейс и внутренности наружу в проде
По умолчанию Symfony в режиме dev отдаёт красивую страницу с трассировкой. В API это превращается в JSON со стектрейсом, если APP_ENV случайно остался dev на сервере или обработчик исключений не настроен. Клиент видит путь к файлам, версии библиотек, иногда SQL-запрос с данными.
Проверьте две вещи. Первое: на проде APP_ENV=prod и APP_DEBUG=0 в окружении. Второе: единый обработчик исключений, который приводит любую ошибку к предсказуемому JSON. Слушатель на событие kernel.exception — рабочий способ:
// src/EventListener/ApiExceptionListener.php
// Symfony 7.x
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
#[AsEventListener(event: 'kernel.exception')]
final class ApiExceptionListener
{
public function __invoke(ExceptionEvent $event): void
{
$request = $event->getRequest();
// Обрабатываем только запросы к API
if (!str_starts_with($request->getPathInfo(), '/api/')) {
return;
}
$exception = $event->getThrowable();
$status = $exception instanceof HttpExceptionInterface
? $exception->getStatusCode()
: 500;
// Для 5xx не отдаём текст исключения наружу
$message = $status < 500
? $exception->getMessage()
: 'Internal server error';
$event->setResponse(new JsonResponse(
['error' => ['status' => $status, 'message' => $message]],
$status,
));
}
}
Логировать полное исключение при этом нужно — но в лог, а не в ответ. Стандартный logger (Monolog) уже пишет исключения, если не отключать его вручную.
Ошибка 3. Валидация запроса руками вместо Validator
Разбор тела запроса через json_decode и ручные if (!isset($data['email'])) — источник дыр. Пропущенное поле, неверный тип, отсутствие проверки формата — всё это всплывёт как 500 где-то ниже по стеку, а не как вежливый 400.
В Symfony 7.x есть #[MapRequestPayload]: он десериализует тело прямо в DTO и прогоняет через Validator до входа в метод контроллера.
// src/Dto/CreateUserRequest.php
use Symfony\Component\Validator\Constraints as Assert;
final readonly class CreateUserRequest
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Email]
public string $email,
#[Assert\NotBlank]
#[Assert\Length(min: 8)]
public string $password,
) {}
}
// В контроллере:
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
#[Route('/api/users', methods: ['POST'])]
public function create(#[MapRequestPayload] CreateUserRequest $dto): JsonResponse
{
// сюда мы попадаем, только если данные прошли валидацию
// ...
}
Если валидация не прошла, Symfony сам вернёт 422 с описанием ошибок. Проверьте, что формат этого ответа устраивает ваших клиентов — при желании его перехватывает тот же слушатель исключений.
Ошибка 4. N+1 запросов на списочных эндпоинтах
Эндпоинт GET /api/orders отдаёт 50 заказов, у каждого — клиент и позиции. Наивный код делает 1 запрос за заказами и по одному за связями каждого — итого сотни запросов на один HTTP-вызов. Локально на трёх записях незаметно, в проде эндпоинт отвечает секундами.
Признак в проде: время ответа растёт линейно с количеством записей, а профайлер (Symfony Profiler или логи Doctrine) показывает десятки почти одинаковых SELECT.
Лечится жадной загрузкой связей в запросе через QueryBuilder:
// В репозитории заказов, Doctrine ORM 3.x
public function findWithRelations(int $limit): array
{
return $this->createQueryBuilder('o')
->addSelect('c', 'i') // тянем связи одним запросом
->leftJoin('o.customer', 'c')
->leftJoin('o.items', 'i')
->setMaxResults($limit)
->getQuery()
->getResult();
}
Осторожно с setMaxResults при join на коллекцию o.items: join размножает строки, и лимит применится к строкам результата, а не к заказам. Для коллекций Doctrine включает HINT_ENABLE_LATE_COLLECTION_LOADING при пагинации через Paginator — используйте Doctrine\ORM\Tools\Pagination\Paginator, а не сырой лимит, если тянете коллекции.
Ошибка 5. Нет версионирования — и любой рефакторинг ломает клиента
Пока API живёт внутри одной команды, версионирование кажется лишним. Проблема появляется в момент, когда у API есть внешний потребитель, которого нельзя обновить синхронно с сервером. Переименовали поле в ответе — мобильное приложение старой версии перестало парсить JSON.
Минимальный рабочий приём — префикс версии в пути: /api/v1/users. Он грубоват, но понятен и не требует инфраструктуры. Разложите контроллеры по неймспейсам App\Controller\Api\V1 и не меняйте контракт v1 после релиза — новое поведение выносите в v2.
| Способ | Плюс | Минус |
|---|---|---|
Префикс в URL (/api/v1/) | Виден в логах и кеше, легко роутить | Дублирование контроллеров между версиями |
Заголовок Accept с версией | URL чистый, «правильно» по REST | Сложнее отлаживать и кешировать, легко забыть |
Параметр запроса (?v=1) | Просто добавить | Ломает кеш по URL, путается с фильтрами |
Для большинства проектов префикс в URL — разумный выбор по умолчанию. Заголовок берут, когда клиенты дисциплинированы и важна чистота адресов.
Ошибка 6. Коды ответов от балды
Возврат 200 на любой исход, включая ошибку с телом {"success": false} — распространённая привычка. Клиенты, прокси и мониторинг ориентируются на HTTP-статус. Если ошибка приходит с кодом 200, её не заметит ни ретрай-логика, ни алертинг.
- 200 — успешное чтение или обновление;
- 201 — создан ресурс, в заголовке
Location— ссылка на него; - 204 — успех без тела (например, удаление);
- 400 — тело запроса не разобрать;
- 404 — ресурса нет;
- 422 — тело разобрали, но данные не прошли валидацию;
- 409 — конфликт, например дубликат уникального поля.
В Symfony бросайте типизированные исключения: NotFoundHttpException, ConflictHttpException — их код подхватит слушатель из ошибки 2.
Что настроить дальше
После того как контракт отделён от сущностей, а ошибки приведены к единому формату, займитесь наблюдаемостью и договором:
- включите логирование медленных запросов Doctrine и следите за временем ответа списочных эндпоинтов под реальным объёмом данных;
- опишите контракт в OpenAPI — даже вручную составленный файл спасает интеграторов от догадок;
- настройте rate limiting через компонент
symfony/rate-limiter, если API публичный; - метрика, за которой стоит смотреть в проде: доля 5xx и p95 времени ответа по каждому маршруту отдельно, а не в среднем по API.
Аутентификация и авторизация API — отдельная большая тема (JWT, OAuth, Security-компонент), здесь она сознательно не затронута.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Symfony: Request Payload (MapRequestPayload), Symfony: Handling Exceptions and 404 Errors (Serializer, kernel.exception)
Частые вопросы
А если у меня Symfony 6.x, атрибут MapRequestPayload сработает?
Обязательно ли делать отдельный DTO, если можно навесить #[Groups] на сущность?
Я всё настроил, но в проде всё равно прилетает стектрейс в ответе. Что проверить?
Как понять, что на эндпоинте именно N+1, а не просто медленная база?
Стоит ли сразу вводить версионирование, если API пока внутренний?
MapRequestPayload возвращает 422 в неудобном для меня формате. Можно поменять?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.