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