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 не сможет сматчить мо