InquirerNo flowers, no visitors: The forgotten graves of UndasThe Jerusalem PostNetanyahu survives the election, but Eisenkot holds key to replacing him - analysisPunchHow Hamzat stepped aside for me in 2018 — Sanwo-OluCNN TürkBeşiktaş, Virtus Bologna deplasmanında kazandıInquirer EntertainmentMargaret Qualley, Jack Antonoff file for divorce after 3 years of marriageBollywood HungamaFilmmaker Sanjay Gupta passes away; director leaves behind a distinctive crime cinema legacyZDF heuteEntdecken Sie das ZDF-NachrichtenstudioUOLTiroteio em loja de Halloween deixa um morto e seis feridos na CalifórniaSouth China Morning PostBoth Japan and China can help Bangladesh reach its full potentialRapplerThe races that will decide control of the US SenateSözcüEverest'te facia: Otel alev alev yandıIl Fatto Quotidiano“Ha ucciso tre parenti servendo loro un filetto con funghi velenosi. Crimini atroci, commessi con una freddezza straordinaria”: Erin Patterson condannata a trascorrere tutta la vita in carcere
The Daily Newsstand · Free, Always
Saturday, October 31, 2026

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

Translate

Классическая ситуация: страница со списком из 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 запросов за авторов.

img1-diagram

img1-diagram

В 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 (...), после чего сам раскладывает авторов по моделям.

img2-before-after

img2-before-after

Полезные варианты:

// Несколько связей и вложенные связи
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():

img3-prevent

img3-prevent

// 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().

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.