API Platform на Symfony: типичные ошибки и как их избежать
Сериализация тянет всю базу, фильтры не работают, а ошибки валидации приходят в формате, который фронтенд не понимает. Разбираем частые грабли API Platform 3.x на Symfony и что с ними делать.

API Platform 3.x на Symfony 6.4/7.x умеет из одной аннотированной сущности собрать REST и GraphQL, документацию OpenAPI и админку. Из-за этого он выглядит обманчиво просто: навесил атрибут #[ApiResource] — и эндпоинт готов. Проблемы начинаются, когда в базе не десять записей, а сто тысяч, когда сущность связана с пятью другими, и когда фронтенд просит вернуть ошибку валидации в предсказуемом виде. Ниже — ошибки, которые повторяются от проекта к проекту, и как их закрыть на уровне конфигурации, а не костылями.
Материал про API Platform версии 3.x. Если у вас 2.x — часть атрибутов называлась иначе (были аннотации и YAML-конфиги вместо PHP-атрибутов), проверяйте по документации своей версии.
Ошибка 1. Сериализация без групп тянет всё подряд
Самая частая проблема: вешаете #[ApiResource] на сущность без указания групп сериализации. API Platform по умолчанию отдаёт все публичные свойства, а вместе со связанными сущностями — и их свойства тоже. Один запрос к /api/orders подтягивает заказы, у каждого — клиента, у клиента — все его адреса, и так далее. Получается либо гигантский JSON, либо лавина SQL-запросов (проблема N+1), либо и то и другое.
Решение — явные группы нормализации. Описываете, что попадает в чтение, а что в запись:
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
normalizationContext: ['groups' => ['order:read']],
denormalizationContext: ['groups' => ['order:write']],
operations: [new GetCollection()]
)]
class Order
{
// В коллекции отдаём только id и статус, без вложенного клиента
#[Groups(['order:read'])]
private ?int $id = null;
#[Groups(['order:read', 'order:write'])]
private string $status;
// Клиент попадёт в ответ, только если явно добавить группу
#[Groups(['order:read'])]
private ?Customer $customer = null;
}
Правило простое: то, чего нет в группе, не сериализуется. Для тяжёлых связей заведите отдельную группу вроде order:read:detail и включайте её только в операции Get (одиночный ресурс), а в GetCollection оставляйте лёгкий набор полей.
Ошибка 2. Проблема N+1 остаётся даже с группами
Группы сокращают объём JSON, но не всегда убирают лишние запросы к базе. Если в группу попала связанная сущность, Doctrine по умолчанию грузит её ленивой загрузкой — по одному запросу на каждую запись коллекции. На сотне заказов это сто дополнительных SELECT.
API Platform умеет автоматически добавлять JOIN через расширение eager loading, но оно ограничено глубиной (по умолчанию — небольшой) и не покрывает сложные случаи. Для контроля пишите свой QueryCollectionExtension и добавляйте нужные джойны явно:
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
final class OrderCustomerExtension implements QueryCollectionExtensionInterface
{
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
): void {
if (Order::class !== $resourceClass) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
// Подтягиваем клиента одним JOIN вместо N запросов
$queryBuilder
->addSelect('customer')
->leftJoin("{$rootAlias}.customer", 'customer');
}
}
Как понять, что N+1 есть: откройте профайлер Symfony (панель Doctrine) на странице коллекции и посмотрите на число запросов. Если оно растёт линейно с числом записей — вы нашли проблему.
Группы сериализации отвечают на вопрос «что отдать», а расширения запроса — на вопрос «как это достать одним запросом». Это две разные настройки, и лечить N+1 группами бесполезно.
Ошибка 3. Пагинация выключена или настроена наугад
По умолчанию API Platform включает пагинацию и отдаёт 30 элементов на страницу. Две крайности, которые встречаются в проде:
- клиент разрешил менять размер страницы без верхнего предела — и кто-то запрашивает
?itemsPerPage=100000, кладя базу; - разработчик отключил пагинацию целиком «чтобы фронтенду было проще» — и коллекция из миллиона строк выгружается за один запрос.
Задайте разумные лимиты в конфиге на уровне ресурса:
#[ApiResource(
paginationEnabled: true,
paginationItemsPerPage: 30,
paginationMaximumItemsPerPage: 100,
paginationClientItemsPerPage: true
)]
class Order { /* ... */ }
Для больших таблиц с частой листалкой рассмотрите курсорную пагинацию (paginationViaCursor) вместо offset-пагинации: она не деградирует на глубоких страницах, потому что не делает OFFSET по огромному смещению. Точные параметры курсора и требования к сортировке смотрите в разделе документации про pagination — там оговорены ограничения по индексам.
Ошибка 4. Фильтры «не работают»
Классическая история: повесили ApiFilter, а запрос ?status=paid возвращает всё подряд. Причин обычно две.
- Фильтр объявлен на свойстве, но не на самом ресурсе. В версии 3.x атрибут
#[ApiFilter]нужно вешать на свойство или на класс, но фильтруемое поле должно быть указано корректно. Опечатка в имени свойства не вызывает ошибку — фильтр просто молча игнорируется. - Не та стратегия сравнения. У
SearchFilterесть стратегии:exact,partial,start,end. По умолчанию для строк применяется не то, что вы ждёте, и поиск «paid» не находит «Paid».
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
#[ApiResource]
#[ApiFilter(SearchFilter::class, properties: [
'status' => 'exact', // точное совпадение
'customer.name' => 'partial' // поиск по подстроке во вложенной сущности
])]
class Order { /* ... */ }
Проверка, что фильтр подхватился: откройте /api/docs (Swagger UI). Если фильтр объявлен правильно, в описании эндпоинта появится соответствующий query-параметр. Нет параметра в документации — фильтр не зарегистрирован, ищите опечатку.
Ошибка 5. Валидация проходит там, где не должна
API Platform запускает валидацию Symfony при денормализации, но ограничения (#[Assert\...]) применяются ко всей сущности одинаково, если не разнести их по группам валидации. На практике это значит: правила для создания (POST) срабатывают и при обновлении (PATCH), хотя при частичном обновлении часть полей приходить не должна.
Разносите ограничения по группам и привязывайте группы к операциям:
use Symfony\Component\Validator\Constraints as Assert;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch;
#[ApiResource(operations: [
new Post(validationContext: ['groups' => ['order:create']]),
new Patch(validationContext: ['groups' => ['order:update']]),
])]
class Order
{
#[Assert\NotBlank(groups: ['order:create'])]
private string $status;
}
Ошибки валидации API Platform по умолчанию отдаёт в формате Hydra/JSON-LD с полем violations. Если фронтенд ждёт RFC 7807 (application/problem+json), убедитесь, что клиент шлёт правильный Accept, а сервер настроен на нужный формат ошибок — иначе фронтенд получит структуру, которую не умеет разбирать.
Ошибка 6. Всё открыто наружу
Без явной настройки безопасности эндпоинты доступны всем, кто дотянется до URL. API Platform интегрирован с Symfony Security и позволяет описывать доступ прямо в операциях выражением на языке Security Expression:
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Patch;
#[ApiResource(operations: [
// Читать может любой аутентифицированный
new Get(security: "is_granted('ROLE_USER')"),
// Менять — только владелец заказа
new Patch(security: "is_granted('ROLE_USER') and object.getCustomer() == user"),
])]
class Order { /* ... */ }
Отдельно проверьте, что чувствительные поля не утекают через группы чтения: пароль, токены, внутренние флаги не должны попадать ни в одну normalizationContext группу. Это отдельная от security настройка — доступ к операции и видимость поля контролируются разными механизмами.
Сводка настроек, которые задают на старте
| Что настраивается | Где | Зачем |
|---|---|---|
| Группы normalization/denormalization | #[ApiResource] + #[Groups] | Контроль состава JSON, борьба с раздутыми ответами |
| QueryExtension с JOIN | Свой класс + сервис-тег | Устранение N+1 |
| paginationMaximumItemsPerPage | #[ApiResource] | Защита от запроса на выгрузку всей таблицы |
| Стратегия SearchFilter | #[ApiFilter] | Чтобы фильтр реально фильтровал |
| Группы валидации по операциям | validationContext | Разные правила для POST и PATCH |
| security-выражения | операции ресурса | Закрыть эндпоинты и защитить чужие данные |
Что делать дальше
Начните с профайлера: откройте панель Doctrine на страницах коллекций и найдите N+1 до того, как их найдёт прод. Затем пройдитесь по каждому ресурсу и ответьте на три вопроса — какие поля отдаём, кто имеет доступ, какой максимум записей на страницу. Для нагрузочной проверки прогоните коллекции на реальном объёме данных, а не на пустой базе: большинство описанных проблем на десяти записях не видно вовсе.
Мониторить в проде стоит время ответа коллекционных эндпоинтов и число SQL-запросов на запрос — рост последнего почти всегда означает новую N+1, которую внесли вместе со свежей связью в группе сериализации.
Источники: API Platform — официальная документация (Core), Symfony Serializer Component
Частые вопросы
У меня API Platform 2.x, эти атрибуты не работают
Добавил группу, а поле всё равно не появляется в ответе
Фильтр объявлен, но в Swagger его нет и он не срабатывает
Как понять, что у меня проблема N+1, если ответ приходит быстро на тесте
Что делать, если ошибки валидации приходят фронтенду в неудобном формате
Отключил пагинацию — теперь коллекция грузится минутами. Это нормально?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.