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 сам покажет все узкие места в вашем коде.