Eloquent ORM: Как оптимизировать сложные SQL-запросы

Eloquent ORM: Как оптимизировать сложные SQL-запросы

Работая с Laravel уже больше семи лет, я не раз видел, как разработчики стреляют себе в ногу, доверяя Eloquent слишком слепо. ORM — это прекрасный инструмент, но магия перестаёт быть магией, если не понимать, что происходит под капотом. Сегодня разберу три больные темы: оптимизацию сложных запросов, проблему N+1 и полиморфные связи. Всё — с реальным кодом, который можно взять и применить.

Проблема N+1: молчаливый убийца производительности

Классика жанра — вывод списка постов с авторами:

$posts = Post::all();

foreach ($posts as $post) {
    echo $post->author?->name ?? 'Автор не указан';
}

Выглядит невинно, но если у нас 100 постов — Laravel выполнит 101 запрос к базе. Один на получение постов, и по одному на каждого автора. Это и есть N+1.

Решение — eager loading через with():

$posts = Post::with('author')->get();

foreach ($posts as $post) {
    echo $post->author?->name ?? 'Автор не указан';
}

Теперь всего 2 запроса: один на посты, второй — на всех авторов через WHERE id IN (…).

Вложенный eager loading

Если нужно подтянуть комментарии и авторов комментариев:

$posts = Post::with('comments.author')->get();

Laravel сам построит цепочку запросов, избежав каскадного N+1.

Условный eager loading

Часто нужно загрузить связь с фильтром. Используем closure:

$posts = Post::with(['comments' => function ($query) {
    $query->where('is_approved', true)->latest();
}])->get();

Eager Loading Count — избегаем лишних join’ов

Если нужно просто узнать количество связанных записей, не загружая их целиком:

$posts = Post::withCount('comments')->get();

foreach ($posts as $post) {
    echo $post->comments_count;
}

Это добавит подзапрос COUNT(*) в основной SELECT не выполняя отдельный запрос для каждого поста.

Ленивая подгрузка после факта

Это тоже избавляет от N+1, просто задним числом.

Бывают ситуации, когда коллекция уже получена, но забыли про eager loading. Не переписывайте запрос — используйте load():

$posts = Post::all();

// Позже в коде понадобились авторы
$posts->load('author');

Оптимизация сложных запросов через кастомные Scope

Когда логика фильтрации повторяется в разных местах приложения, я всегда выношу её в Local Scope. Это не только чистит код, но и позволяет цеплять условия цепочкой:

class Post extends Model
{
    public function scopePublished($query)
    {
        return $query->where('status', 'published')
                     ->whereNotNull('published_at');
    }

    public function scopeWithPopularComments($query)
    {
        return $query->with(['comments' => function ($q) {
            $q->orderByDesc('likes_count')->limit(5);
        }]);
    }
}

Использование:

$posts = Post::published()
    ->withPopularComments()
    ->withCount('comments')
    ->paginate(20);

Один читаемый вызов вместо портянки условий.

Оптимизация через select и chunk

Ещё одна частая ошибка — тянуть все колонки, когда нужны только пара полей. Особенно критично в связях:

$posts = Post::with(['author:id,name,email'])
    ->select('id', 'title', 'author_id')
    ->get();

Обратите внимание: если используете select с ограничением колонок в связи, обязательно включайте внешний ключ (author_id) — иначе Eloquent не сможет сматчить модели.

Для больших объёмов данных, где get() съест всю память, использую chunk() или lazy():

Post::with('author')->chunk(200, function ($posts) {
    foreach ($posts as $post) {
        // обработка
    }
});

// Или через ленивую коллекцию
Post::with('author')->lazy()->each(function ($post) {
    // обработка одной записи за раз, экономим память
});

Полиморфные связи: гибкость без хаоса

Полиморфные отношения нужны, когда одна модель может принадлежать нескольким разным сущностям. Классический пример — комментарии к постам и видео.

class Comment extends Model
{
    public function commentable()
    {
        return $this->morphTo();
    }
}

class Post extends Model
{
    public function comments()
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

class Video extends Model
{
    public function comments()
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

Миграция для таблицы comments:

Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->text('body');
    $table->morphs('commentable'); // создаёт commentable_id и commentable_type
    $table->timestamps();
});

Eager loading для полиморфных связей

Тут кроется главная засада. Если делать вот так:

$comments = Comment::with('commentable')->get();

Eloquent выполнит один запрос для комментариев и по одному запросу для каждого уникального значения commentable_type. Это не классический N+1, а схема 1 + T, где T — количество типов моделей.

$comments = Comment::with(['commentable' => function ($morphTo) {
    $morphTo->morphWith([
        Post::class => ['author'],
        Video::class => ['channel'],
    ]);
}])->get();

Позволяет задать, какие вложенные связи нужно eager-load для каждого типа.

Полиморфные many-to-many

Для тегов, которые могут прикрепляться к разным моделям, использую morphToMany:

class Tag extends Model
{
    public function posts()
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }

    public function videos()
    {
        return $this->morphedByMany(Video::class, 'taggable');
    }
}

class Post extends Model
{
    public function tags()
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

Миграция:

Schema::create('taggables', function (Blueprint $table) {
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->morphs('taggable');
});

Отладка: находим N+1 до продакшена

Я всегда добавляю в AppServiceProvider защиту от «ленивой загрузки», которая молча тянет запросы:

public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}

В связке с пакетом laravel-debugbar или встроенным DB::listen() это моментально показывает, где забыли with():

DB::listen(function ($query) {
    if (app()->environment('local')) {
        Log::info($query->sql, $query->bindings);
    }
});

Итог

Eloquent действительно творит магию, но фокусы работают только если знаешь секрет. Eager loading, продуманные scope’ы, работа с полиморфными связями через morphWith и preventLazyLoading в связке — это не разовые трюки, а привычки, которые должны войти в вашу повседневную практику. Начните с малого: включите preventLazyLoading в дев-окружении прямо сегодня, и Laravel сам покажет все узкие места в вашем коде.