N+1 запросов в Laravel: как один foreach превращается в 101 SELECT и как это запретить

Классическая ситуация: страница со списком из 100 постов локально открывается мгновенно, а на проде с реальными данными — заметно тормозит. Профайлер показывает не один тяжёлый запрос, а сотню лёгких одинаковых. Это проблема N+1, и в Laravel её очень легко создать, потому что Eloquent по умолчанию загружает связи лениво.
Откуда берётся N+1
// app/Models/Post.php
class Post extends Model
{
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}
$posts = Post::latest()->take(100)->get(); // 1 запрос
foreach ($posts as $post) {
echo $post->author->name; // +1 запрос на КАЖДЫЙ пост
}
Обращение к $post->author — это не поле, а магическое свойство, которое при первом доступе выполняет SELECT * FROM users WHERE id = ?. Итого 1 запрос за список + N запросов за авторов.

В Blade это выглядит ещё невиннее — {{ $post->author->name }} внутри @foreach, и никакого «запроса» глазами не видно.
Как увидеть проблему
Самый простой способ без пакетов — посчитать запросы:
use Illuminate\Support\Facades\DB;
DB::enableQueryLog();
$posts = Post::latest()->take(100)->get();
foreach ($posts as $post) {
$post->author->name;
}
dump(count(DB::getQueryLog())); // 101
Или логировать каждый запрос в dev-окружении:
// AppServiceProvider::boot()
DB::listen(function ($query) {
logger()->debug($query->sql, $query->bindings);
});
Удобнее всего — Laravel Debugbar или Telescope: там сразу видно количество запросов и одинаковые повторяющиеся SQL.
Решение: eager loading
$posts = Post::with('author')->latest()->take(100)->get();
foreach ($posts as $post) {
echo $post->author->name; // запросов больше нет
}
Теперь Eloquent делает ровно два запроса: посты и SELECT * FROM users WHERE id IN (...), после чего сам раскладывает авторов по моделям.

Полезные варианты:
// Несколько связей и вложенные связи
Post::with(['author', 'comments.author'])->get();
// Только нужные колонки (внешний ключ id обязателен!)
Post::with('author:id,name')->get();
// Нужно только количество — не грузим сами комментарии
Post::withCount('comments')->get(); // $post->comments_count
// Коллекция уже загружена — догружаем связь одним запросом
$posts->load('author');
$posts->loadMissing('author'); // только если ещё не загружена
Почему withCount лучше, чем $post->comments->count()
$post->comments->count() сначала загружает все комментарии каждого поста в память (и без with — ещё и N запросов), а потом считает элементы коллекции. $post->comments()->count() со скобками делает SELECT COUNT(*) — но тоже на каждой итерации, то есть снова N+1.
withCount('comments') добавляет подзапрос в основной SELECT, и вы получаете готовое поле comments_count за один запрос.
Ловушка: N+1 внутри accessor-ов и ресурсов
Даже если в контроллере стоит with('author'), N+1 может вернуться через API Resource:
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
// если связь не загружена — здесь будет ленивый запрос
'author' => new UserResource($this->whenLoaded('author')),
'comments' => $this->whenCounted('comments'),
];
}
}
whenLoaded() выводит связь только если она уже загружена, а не дёргает базу молча.
Запрещаем ленивую загрузку на уровне фреймворка
Лучший способ не ловить N+1 на проде — сделать так, чтобы он падал в разработке. С Laravel 8.43 есть Model::preventLazyLoading():

// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}
Теперь любое обращение к незагруженной связи в dev и тестах бросит LazyLoadingViolationException. На проде исключения не будет — можно вместо этого логировать:
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
logger()->warning('Lazy loading', [
'model' => get_class($model),
'relation' => $relation,
]);
});
А Model::shouldBeStrict() включает сразу три проверки: запрет ленивой загрузки, ошибку при обращении к отсутствующему атрибуту и ошибку при попытке молча присвоить неразрешённое для mass assignment поле.
Тот же N+1 — не только в Eloquent
Проблема не привязана к Laravel: тот же паттерн «запрос в цикле» встречается в чистом PHP с PDO, в Django ORM и в GraphQL-резолверах. В GraphQL её обычно решают через DataLoader, который собирает id из всех резолверов и делает один батч-запрос.
Чек-лист
Видите
$model->relationвнутриforeach/@foreach— проверьте, есть лиwith().Нужно число —
withCount(), а не->count()по связи.В ресурсах —
whenLoaded()/whenCounted().В
AppServiceProvider—Model::preventLazyLoading(! app()->isProduction()).В тестах можно проверять количество запросов через
DB::getQueryLog().
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.