Классическая ситуация: страница со списком из 100 постов локально открывается мгновенно, а на проде с реальными данными — заметно тормозит. Профайлер показывает не один тяжёлый запрос, а сотню лёгких одинаковых. Это проблема N+1, и в Laravel её очень легко создать, потому что Eloquent по умолчанию загружает связи лениво.
Откуда берётся N+1
// app/Models/Post.phpclass 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.phpuse 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 из всех резолверов и делает один батч-запрос — короткое объяснение есть в шортсе:
https://www.youtube.com/shorts/o92gtJKXgMc
Чек-лист
-
Видите
$model->relationвнутриforeach/@foreach— проверьте, есть лиwith(). -
Нужно число —
withCount(), а не->count()по связи. -
В ресурсах —
whenLoaded()/whenCounted(). -
В
AppServiceProvider—Model::preventLazyLoading(! app()->isProduction()). -
В тестах можно проверять количество запросов через
DB::getQueryLog().
Если хочется системно разобрать Eloquent, связи, очереди и остальной Laravel — есть бесплатный курс с практикой: Laravel на Субконто.Академии.
ссылка на оригинал статьи https://habr.com/ru/articles/1101990/