Жадібне завантаження (Eager Loading) через with() прибирає проблему N+1 лише в одному напрямку - від батьківської моделі до дочірніх. Варто в циклі по коментарях звернутися назад до поста, і Eloquent знову піде в базу за кожним коментарем, хоча пост уже лежить у пам'яті. На цей випадок у фреймворку є два інструменти, про які знають небагато: chaperone() і автоматичне жадібне завантаження зв'язків.
Загальну механіку N+1 і with() ми вже розбирали в статтях «Найдешевший запит - той, якого немає» і «Зупинка проблеми N+1 запитів», тож тут лише зворотний напрямок. Відправною точкою стали дві поради Джоела Клермонта з Mastering Laravel - про chaperone() і про автоматичне завантаження; назви методів і поведінку звірили з документацією Laravel 13.
Звідки береться 14 запитів
Візьмімо звичайну пару: Post має багато Comment (hasMany), а Comment належить Post (belongsTo). У базі 3 пости і 12 коментарів. Виводимо коментарі разом із заголовком їхнього поста:
$posts = Post::with('comments')->get();
foreach ($posts as $post) {
foreach ($post->comments as $comment) {
echo $comment->post->title;
}
}
Тут 14 запитів: два на with() (пости і всі їхні коментарі) і ще по одному на кожен із 12 коментарів. Причина в тому, що коментар не знає, через який пост його завантажили. Для нього post - звичайний ще не завантажений зв'язок, тож $comment->post робить ліниве завантаження (Lazy Loading). Документація прямо називає це N+1: Eloquent не гідратує батьківський Post у кожен дочірній Comment автоматично.
chaperone(): батько без жодного запиту
Метод chaperone() просить Eloquent під час завантаження дочірніх моделей одразу проставити кожній батьківську. Його можна закріпити на зв'язку:
public function comments(): HasMany
{
return $this->hasMany(Comment::class)->chaperone();
}
Той самий цикл тепер робить 2 запити, скільки б коментарів не було. Важливо, що $comment->post - не копія, а той самий екземпляр моделі, що й $post у зовнішньому циклі: зміна одного видна в іншому.
Якщо не хочеться вмикати це для зв'язку назавжди, chaperone() можна викликати точково, під час жадібного завантаження:
$posts = Post::with([
'comments' => fn ($comments) => $comments->chaperone(),
])->get();
Назву зворотного зв'язку Eloquent вгадує з зовнішнього ключа або назви батьківської моделі. Якщо в Comment зв'язок названо інакше, наприклад article(), передайте назву явно: chaperone('article').
chaperone() працює для hasMany і morphMany, а з Laravel 13.31 - і для власних pivot-моделей у belongsToMany, про що ми писали в огляді Laravel 13.31. Сам метод з'явився ще в Laravel 11.22 (PR #51582).
Автоматичне жадібне завантаження
Другий інструмент ширший. З Laravel 12.8 (PR #53655) можна попросити фреймворк самому довантажувати зв'язки, до яких ви звертаєтеся. Вмикається глобально, у boot() вашого AppServiceProvider:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::automaticallyEagerLoadRelationships();
}
Тепер той самий цикл можна писати взагалі без with():
$posts = Post::all();
foreach ($posts as $post) {
foreach ($post->comments as $comment) {
echo $comment->post->title;
}
}
Коли ви вперше звертаєтеся до $post->comments, Laravel одним запитом завантажує коментарі для всіх постів колекції. Коли вперше звертаєтеся до $comment->post - так само завантажує пости для всіх коментарів. Разом 3 запити замість 16, які цей код зробив би без with() і без автозавантаження.
Якщо глобально вмикати не хочеться, автозавантаження можна ввімкнути для однієї колекції:
$posts = Post::all()->withRelationshipAutoloading();
Чим вони відрізняються і як працюють разом
Третій запит у прикладі вище - якраз та різниця. Автоматичне завантаження все одно йде в базу по пости, хоча вони вже завантажені, і повертає окремі екземпляри. Змініть щось у $comment->post - і $post у зовнішньому циклі цього не побачить. chaperone() цей запит пропускає і віддає той самий об'єкт.
Інструменти не конкурують. Якщо ввімкнути автоматичне завантаження і додати chaperone() на зв'язок comments(), цикл знову зводиться до 2 запитів. Підсумок для нашого прикладу:
with('comments') - 14 запитів;
with('comments') і chaperone() - 2;
- автоматичне жадібне завантаження без
with() - 3;
- автоматичне завантаження і
chaperone() - 2.
Застереження
chaperone() змінює вміст моделей. Кожен коментар тепер має завантажений зв'язок post, тож toArray() і JSON-серіалізація вкладають батьківську модель у кожну дочірню. Якщо ви віддаєте моделі з API напряму, без API Resources, дані відповіді зміняться - саме тому chaperone() не ввімкнено за замовчуванням. Вмикайте його свідомо і перевірте, що повертають ваші ендпоінти.
Статус «beta» в автозавантаження вже зняли, але лише в документації 13.x. Порада Mastering Laravel застерігає, що документація позначає функцію як beta, поведінка якої може змінитися навіть у патч-релізах. Такий блок досі стоїть у документації 12.x, а з документації 13.x його прибрали в серпні: PR #11317 змерджив сам Тейлор Отвелл. Тобто для Laravel 13 документація функцію експериментальною вже не вважає, а на 12.x попередження лишається чинним.
Власні колекції. Якщо модель повертає свою колекцію через перевизначений newCollection(), автозавантаження саме собою на неї не поширюється. Документація до власних колекцій показує, що треба додати перевірку Model::isAutomaticallyEagerLoadingRelationships() і викликати withRelationshipAutoloading() на новій колекції.
Що з цього вмикати
chaperone() варто додати на hasMany-зв'язки, де ви регулярно ходите від дитини до батька: коментарі й пости, рядки замовлення й замовлення. Запиту він не додає взагалі, а єдина ціна - вкладена модель у серіалізації. Де моделі йдуть в API без ресурсів, обирайте точковий виклик у with([...]).
- Автоматичне жадібне завантаження зручне як страховка на випадок, коли ви не знаєте наперед, які зв'язки знадобляться, - наприклад, у шаблонах із розгалуженою логікою. Але воно не скасовує явних
with(): явне завантаження видно в коді, а автоматичне ховає запити від того, хто читає контролер.
- Перед глобальним увімкненням на великому проєкті почніть з
withRelationshipAutoloading() на кількох важких сторінках і подивіться на лічильник запитів, наприклад у Laravel Debugbar чи Telescope. Якщо ви ще на Laravel 12, зважайте на статус beta.
Model::preventLazyLoading() у середовищі розробки лишається найпростішим способом помітити, де взагалі ховається N+1, - а вже потім вирішувати, чим його лагодити.