Doctrine в Symfony: чек-лист перед выкаткой в прод
Doctrine ORM удобна, пока проект небольшой. На реальной нагрузке всплывают N+1, ленивые загрузки в цикле и запросы, которые тянут пол-базы. Чек-лист, что проверить до релиза.

Приложение на Symfony держится под нагрузкой ровно до того момента, пока кто-нибудь не откроет список из двухсот заказов, каждый со своим клиентом и позициями. В логах внезапно появляются сотни SQL-запросов на одну страницу, время ответа уходит за секунду, а Doctrine выглядит виноватой. Она не виновата — виноват код, который не проверили. Ниже практический чек-лист для проектов на Symfony 6.x/7.x с Doctrine ORM 2.x/3.x: что пройти по пунктам перед тем, как выкатывать фичу с запросами к базе.
Формат — практический разбор типичных проблем, а не введение в ORM. Предполагается, что вы уже пишете сущности и репозитории, но хотите убрать грабли, на которые наступают на нагрузке. Миграции и настройку соединения здесь не разбираем — только работу с данными в рантайме.
1. Проблема N+1: главный источник тормозов
N+1 — это когда вы получили список из N сущностей одним запросом, а потом обращаетесь к связанной сущности каждого элемента в цикле, и Doctrine делает ещё N запросов. По умолчанию связи ManyToOne и OneToOne грузятся лениво (fetch="LAZY"): пока вы не тронули поле — запроса нет, тронули — есть.
Классический пример: рендер списка заказов, где в шаблоне выводится имя клиента.
// Так возникает N+1: 1 запрос на заказы + по 1 на каждого клиента
$orders = $orderRepository->findAll();
foreach ($orders as $order) {
echo $order->getCustomer()->getName(); // ленивый запрос здесь
}Лечится JOIN FETCH в DQL или QueryBuilder — связанные данные приезжают одним запросом:
// OrderRepository, Doctrine ORM 2.x/3.x
public function findAllWithCustomer(): array
{
return $this->createQueryBuilder('o')
->addSelect('c') // без addSelect клиент не попадёт в результат
->leftJoin('o.customer', 'c')
->getQuery()
->getResult();
}Ключевой момент — addSelect('c'). Без него JOIN отработает для фильтрации, но данные клиента всё равно догрузятся лениво, и вы получите тот же N+1, только с лишним JOIN.
Если после добавления фичи число запросов на странице выросло пропорционально числу строк в списке — у вас N+1. Это не «медленно из-за большого объёма», это архитектурная ошибка запроса.
Как поймать N+1 до прода
Установите Symfony Profiler (пакет symfony/web-profiler-bundle) в dev-окружении. На вкладке Doctrine видно точное число запросов и дубли. Правило простое: открыли страницу списка — посмотрели счётчик. Десятки одинаковых запросов с разным ID в WHERE — сигнал.
2. Коллекции: EAGER, LAZY и когда какой
Для связей OneToMany и ManyToMany по умолчанию действует ленивая загрузка. Соблазн поставить fetch="EAGER" прямо в маппинге, чтобы «всегда грузилось», — ловушка: тогда связь тянется всегда, даже когда вам нужна одна сущность без коллекции.
| Стратегия | Когда запрос | Риск |
|---|---|---|
| LAZY (по умолчанию) | При первом обращении к полю | N+1 в циклах |
| EAGER | Всегда вместе с сущностью | Лишние данные там, где связь не нужна |
| EXTRA_LAZY | Точечно: count, contains без загрузки всей коллекции | Не панацея, только для больших коллекций |
Практичный подход: оставляйте LAZY в маппинге, а в конкретных запросах, где связь нужна, добавляйте JOIN FETCH. Так вы управляете загрузкой на уровне сценария, а не на уровне сущности. Для больших коллекций, где вам нужен только ->count() или проверка ->contains(), посмотрите в сторону fetch="EXTRA_LAZY" — она умеет считать без выборки всех строк.
3. Гидратация: не тяните объекты, если нужны данные
По умолчанию getResult() возвращает полноценные объекты-сущности (гидратация OBJECT). За это платят память и время: Doctrine создаёт объекты, регистрирует их в UnitOfWork и отслеживает изменения. Для отчёта или API, где данные только читаются и сразу уходят в JSON, это лишнее.
getArrayResult()— массивы вместо объектов, заметно легче на больших выборках;getScalarResult()— плоский результат для агрегатов и простых выборок;- DTO через
SELECT NEW— сразу собираете нужный объект без полей, которые не используете.
// Гидратация в DTO: только те поля, что реально нужны
$dtos = $this->createQueryBuilder('o')
->select('NEW App\\Dto\\OrderListItem(o.id, o.createdAt, c.name)')
->leftJoin('o.customer', 'c')
->getQuery()
->getResult();DTO-класс должен иметь конструктор с параметрами в том же порядке, что в SELECT NEW. Это отдельная тема — детали конструкторной гидратации смотрите в документации Doctrine по разделу DQL SELECT NEW.
4. Управление памятью в пакетной обработке
Импорт или массовое обновление — типичное место утечки памяти. Пока вы в цикле создаёте сущности и вызываете persist(), Doctrine держит их все в EntityManager. На десятках тысяч строк процесс упирается в лимит памяти PHP.
// Пакетная вставка с очисткой каждые 100 записей
$batchSize = 100;
foreach ($rows as $i => $row) {
$product = new Product();
$product->setName($row['name']);
$entityManager->persist($product);
if (($i % $batchSize) === 0) {
$entityManager->flush();
$entityManager->clear(); // отвязывает все управляемые сущности
}
}
$entityManager->flush(); // хвост, который не попал в последний batchПосле clear() все ранее полученные сущности становятся отсоединёнными: обращаться к ним нельзя, ссылки на них ведут в никуда. Если внутри цикла вам нужна какая-то справочная сущность (например, категория), получайте её заново по ID после каждого clear или используйте getReference().
5. Пагинация и большие выборки
Не используйте findAll() там, где строк может быть много — вы получите всю таблицу в память. Для постраничного вывода в Doctrine есть Doctrine\ORM\Tools\Pagination\Paginator, который корректно считает общее число строк при наличии JOIN на коллекции (обычный setMaxResults с JOIN на OneToMany обрезает не строки, а декартово произведение).
Для потоковой обработки читайте результат через toIterable() — он отдаёт сущности по одной, не загружая весь результат. Но помните: под капотом это работает с курсором, и внутри итерации нельзя делать запросы, которые сбивают его состояние.
6. Индексы и то, что ORM не сделает за вас
Doctrine генерирует SQL, но не догадается, что колонке нужен индекс. Если вы фильтруете по полю в WHERE или соединяете по нему, а индекса нет — база делает полный проход по таблице. Индексы объявляются в маппинге сущности через атрибут #[ORM\Index] и попадают в миграцию, но проверять реальный план запроса нужно на стороне БД через EXPLAIN, а не на глазок.
Чек-лист перед выкаткой
- Открыл каждую новую страницу-список в Profiler, посмотрел число SQL-запросов. Дубли с разным ID — исправил через JOIN FETCH с addSelect.
- Проверил, что нет
fetch="EAGER", добавленного «на всякий случай». - Для чтения-только выборок (API, отчёты) заменил гидратацию OBJECT на array/DTO там, где объекты не нужны.
- В пакетной обработке есть flush + clear по батчам, а не один flush в конце на всё.
- Списки, которые могут вырасти, идут через Paginator, а не findAll.
- Поля из WHERE и JOIN проверены на наличие индекса, план запроса посмотрен через EXPLAIN.
Что делать дальше
Настройте логирование медленных запросов на стороне БД (в PostgreSQL — log_min_duration_statement) и повесьте счётчик числа SQL-запросов на страницу в мониторинг — так вы поймаете регресс N+1 не в проде от жалоб пользователей, а на графике. Следующий шаг после запросов — кеширование результатов через Doctrine Result Cache, но это отдельная тема со своей инвалидацией; сначала уберите лишние запросы, потом кешируйте оставшиеся.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Doctrine ORM Documentation — Improving Performance, Symfony — The Profiler
Частые вопросы
У меня Doctrine ORM 3.x, а примеры для 2.x — сработают?
Добавил JOIN FETCH, но связь всё равно грузится отдельным запросом. Почему?
Могу я просто поставить EAGER на все связи и забыть про N+1?
После clear() в пакетном цикле падает ошибка про detached entity — что делать?
Как понять, что проблема именно в Doctrine, а не в самой базе?
Paginator с JOIN на коллекцию считает неправильное число страниц. Это баг?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.