Розробник, який переходить з Laravel на Symfony або навпаки, тягне за собою звичку лагодити N+1 єдиним відомим способом. У Doctrine with() не існує, у Eloquent немає fetch join, і спроба відтворити знайомий прийом закінчується або зайвими запитами, або декартовим добутком. Розберемо три сценарії, у яких N+1 виникає найчастіше, у кожному код до і після для обох ORM. Приклади на PHP 8.4, Laravel 13 і Doctrine ORM 3.
Механіка під капотом різна
Eloquent не має identity map. $article->author при першому звертанні виконує select * from users where id = ? і кладе результат у $relations конкретного екземпляра. Сусідній $article про цей запит не знає. Eager loading тут зводиться до другого запиту з whereIn по зібраних ключах і розкладання результату по моделях у PHP.
Doctrine має identity map і проксі. Коли ви гідратуєте Article, поле author отримує неініціалізований proxy з одним лише ідентифікатором. Перше звертання до будь-якого геттера, крім getId(), тригерить SELECT. Тому N+1 у Doctrine візуально непомітний: у коді немає нічого, що виглядає як запит. Натомість identity map дає те, чого немає в Eloquent: якщо потрібна сутність уже завантажена, звертання до неї безкоштовне, а повторна вибірка гідрує дані в уже створений proxy й позначає його ініціалізованим.
Сценарій 1: список і звʼязок to-one
Класика жанру: стрічка статей з іменем автора.
// Eloquent: 1 + 20 запитів
$articles = Article::query()->latest('published_at')->limit(20)->get();
foreach ($articles as $article) {
echo $article->author->name;
}
// 2 запити
$articles = Article::query()->with('author')->latest('published_at')->limit(20)->get();
Пастка, на яку натикаються регулярно: звуження колонок ламає eager loading.
Article::query()->select('id', 'title')->with('author')->get();
author_id не вибраний, збирати ключі нема з чого, і $article->author буде null без жодної помилки. У select() завжди має бути зовнішній ключ звʼязку.
Doctrine у тій самій ситуації:
// 1 + 20 запитів: getName() ініціалізує proxy
$articles = $em->createQuery(
'SELECT a FROM App\Entity\Article a ORDER BY a.publishedAt DESC'
)->setMaxResults(20)->getResult();
foreach ($articles as $article) {
echo $article->getAuthor()->getName();
}
Перша спроба зазвичай така: додати JOIN a.author au. Результат той самий, бо JOIN впливає на набір рядків, але не на гідрацію. Щоб автор потрапив у результат, його аліас має бути в SELECT.
// 1 запит
$articles = $em->createQuery('
SELECT a, au
FROM App\Entity\Article a
JOIN a.author au
ORDER BY a.publishedAt DESC
')->setMaxResults(20)->getResult();
Те саме через QueryBuilder робиться за допомогою ->select('a', 'au') або ->addSelect('au') після join. Виносити це в мапінг через fetch: 'EAGER' спокусливо, але тоді автор вантажиться в кожному запиті за статтями, включно з тими, де він не потрібен. Рішення про fetch join належить місцю запиту, а не мапінгу.
Сценарій 2: вкладені звʼязки
Статті, їхні коментарі й автори коментарів. В Eloquent це крапкова нотація:
$articles = Article::query()
->with(['comments' => fn ($query) => $query->latest()->limit(3), 'comments.author'])
->paginate(20);
Три запити незалежно від кількості статей. limit(3) всередині eager load працює з Laravel 11 через віконні функції, тож потрібні MySQL 8+, PostgreSQL або SQLite 3.25+.
З поліморфними звʼязками крапкова нотація не працює, бо цільова модель заздалегідь невідома. Для них є morphWith:
Comment::query()->with(['commentable' => fn (MorphTo $morphTo) => $morphTo->morphWith([
Article::class => ['author'],
Vacancy::class => ['company'],
])])->get();
З Laravel 12.8 доступне автоматичне довантаження: Model::automaticallyEagerLoadRelationships() у провайдері або $articles->withRelationshipAutoloading() на конкретній колекції. Перше звертання до $article->comments підтягує коментарі для всієї колекції одразу. Зручно для Blade-шаблонів, де важко передбачити, до чого дійде рендер, але це не привід прибрати with() з запитів, які ви контролюєте: автозавантаження врятує від N+1 і жодним чином не підкаже, що запит узагалі був зайвий.
Doctrine розвʼязує той самий випадок ланцюжком fetch join:
$articles = $em->createQuery('
SELECT a, c, cu
FROM App\Entity\Article a
LEFT JOIN a.comments c
LEFT JOIN c.author cu
WHERE a.id IN (:ids)
')->setParameter('ids', $ids)->getResult();
Тут є два обмеження. Перше: setMaxResults() разом із fetch join колекції дає неправильний результат, бо LIMIT 20 застосовується до рядків SQL, а не до кореневих сутностей. Стаття з пʼятьма коментарями зʼїдає пʼять рядків ліміту. Для цього є Doctrine\ORM\Tools\Pagination\Paginator:
use Doctrine\ORM\Tools\Pagination\Paginator;
$query = $em->createQuery('
SELECT a, c FROM App\Entity\Article a LEFT JOIN a.comments c ORDER BY a.publishedAt DESC
')->setFirstResult($offset)->setMaxResults(20);
$paginator = new Paginator($query, fetchJoinCollection: true);
$total = count($paginator);
Усередині це три запити: підрахунок, вибірка ідентифікаторів кореневих сутностей з лімітом, потім основний запит із WHERE id IN (...). setUseOutputWalkers(false) прискорює другий крок, але ламається на запитах із HAVING та сортуванням по полях приєднаної колекції, тож вимикати walker'и варто тільки після перевірки на реальному запиті.
Друге обмеження: два fetch join до різних колекцій в одному запиті множать рядки. Статтю з 10 коментарями й 5 тегами ви отримаєте 50 разів. Doctrine схлопне це в одну сутність завдяки identity map, але через мережу проїде вся матриця. Розділяйте на два запити.
Для to-one звʼязків у Doctrine є прийом, якого немає в Eloquent: прогрів identity map. Якщо колекція коментарів уже завантажена й потрібні їхні автори, достатньо одного запиту без будь-якого join:
$authorIds = array_unique(array_map(
static fn (Comment $comment): int => $comment->getAuthorId(),
$comments,
));
$em->getRepository(User::class)->findBy(['id' => $authorIds]);
// Проксі в $comment->getAuthor() тепер ініціалізовані, нових запитів не буде.
На колекції це не працює: PersistentCollection ініціалізується власним запитом незалежно від того, що лежить в identity map.
Сценарій 3: агрегати
Кількість коментарів під кожною статтею дає найтихіший різновид N+1: у шаблоні це один невинний виклик.
// N запитів COUNT
foreach ($articles as $article) {
echo $article->comments()->count();
}
// Ще гірше: N запитів, кожен тягне всі рядки
foreach ($articles as $article) {
echo $article->comments->count();
}
// Один запит на все
$articles = Article::query()
->withCount(['comments as approved_comments_count' => fn ($query) => $query->where('approved', true)])
->withSum('views', 'count')
->withExists('reports')
->get();
Далі читається як $article->approved_comments_count. Для вже завантаженої колекції є loadCount() і loadSum().
withCount будує корельований підзапит на кожну колонку. Це робить його імунним до дублювання при кількох агрегатах одночасно, але на вибірці в тисячі рядків підзапит виконується для кожного рядка, і одиничний GROUP BY з join може виявитись швидшим. Для сторінки на 20 записів різниці немає, для експорту на 50 тисяч - є.
Doctrine пропонує два шляхи. Перший - fetch: 'EXTRA_LAZY' на колекції:
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'article', fetch: 'EXTRA_LAZY')]
private Collection $comments;
Тоді $article->getComments()->count() виконує SELECT COUNT(*) замість завантаження всіх сутностей, а slice() і contains() теж працюють без повної гідрації. Для однієї сторінки статті це правильний вибір, для списку - ні: запитів усе одно буде стільки, скільки статей.
Другий шлях - порахувати все в DQL:
$rows = $em->createQuery('
SELECT a, COUNT(c.id) AS commentCount
FROM App\Entity\Article a
LEFT JOIN a.comments c
GROUP BY a.id
ORDER BY a.publishedAt DESC
')->setMaxResults(20)->getResult();
foreach ($rows as $row) {
$article = $row[0];
$count = (int) $row['commentCount'];
}
Форма результату тут інша: масив, де під ключем 0 лежить сутність, а агрегати сидять під своїми аліасами, причому COUNT приходить рядком, і приведення до int на вашій совісті. Якщо агрегат потрібен лише для сортування, є HIDDEN, і тоді getResult() віддає звичайні сутності:
SELECT a, COUNT(c.id) AS HIDDEN commentCount
FROM App\Entity\Article a
LEFT JOIN a.comments c
GROUP BY a.id
ORDER BY commentCount DESC
І те, чого withCount уникає автоматично: два LEFT JOIN до різних колекцій в одному GROUP BY дадуть перемножені лічильники. Або COUNT(DISTINCT c.id), або окремі запити.
Як це ловити, а не помічати на проді
У Laravel є вимикач, який перетворює ліниве завантаження на виняток:
// AppServiceProvider::boot()
Model::preventLazyLoading(! $this->app->isProduction());
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation): void {
Log::warning('Lazy loading', ['model' => $model::class, 'relation' => $relation]);
});
У продакшені кидати LazyLoadingViolationException ризиковано, тому типова схема така: локально й у CI - виняток, на проді - запис у лог. Список порушень у логах за тиждень дає точну карту місць, де забули with().
У Doctrine аналога немає, тож лічильник запитів доводиться ставити самому. У Symfony це профайлер у dev і Doctrine\DBAL\Logging\Middleware з PSR-3 логером там, де профайлера немає. Але надійніше за будь-який дашборд працює тест, який фіксує кількість запитів на конкретний ендпоінт:
it('віддає стрічку трьома запитами', function (): void {
Article::factory()->count(20)->hasComments(3)->create();
DB::enableQueryLog();
$this->get(route('articles.index'))->assertOk();
expect(DB::getQueryLog())->toHaveCount(3);
});
Такий тест ловить регресію в момент, коли хтось додає в шаблон нове поле зі звʼязку. Без нього N+1 повертається тихо: сторінка працює, тести зелені, а час відповіді росте пропорційно до того, як наповнюється база.