Eloquent ORM — это сердце Laravel. С его помощью работа с базой данных выглядит как работа с обычными PHP-объектами. Но без понимания механизма связей и проблемы N+1 легко получить код, который делает сотни лишних запросов к БД и кладёт сервер под нагрузкой. В этой статье разберём все типы связей, жадную загрузку и типичные ошибки.
Как работают связи в Eloquent
Связи в Eloquent определяются методами внутри классов-моделей. Каждый метод возвращает объект типа Relation, который описывает, как модели соединены между собой. Laravel сам формирует SQL-запросы, вам не нужно писать JOIN вручную.
Базовая структура модели с несколькими связями:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected $fillable = ['name', 'email'];
// Один пользователь — один профиль
public function profile()
{
return $this->hasOne(Profile::class);
}
// Один пользователь — много статей
public function posts()
{
return $this->hasMany(Post::class);
}
// Пользователь принадлежит одной компании
public function company()
{
return $this->belongsTo(Company::class);
}
// Пользователь — много ролей (через сводную таблицу)
public function roles()
{
return $this->belongsToMany(Role::class);
}
}
hasOne — один-к-одному
hasOne используется, когда у одной записи есть ровно одна связанная запись в другой таблице. Классический пример — пользователь и его профиль.
// Миграция для таблицы profiles
// В ней должен быть user_id (foreign key)
Schema::create('profiles', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('bio')->nullable();
$table->string('avatar')->nullable();
$table->timestamps();
});
// Модель Profile — обратная связь
class Profile extends Model
{
public function user()
{
return $this->belongsTo(User::class);
}
}
// Использование
$user = User::find(1);
echo $user->profile->bio; // Обращаемся как к свойству
// Создание связанной записи
$user->profile()->create([
'bio' => 'PHP-разработчик',
'avatar' => 'avatar.jpg',
]);
Если имена ключей нестандартные — можно указать их явно: hasOne(Profile::class, 'foreign_key', 'local_key').
hasMany — один-ко-многим
Самая распространённая связь. Один пользователь пишет много статей, одна категория содержит много товаров.
// Модель Post
class Post extends Model
{
protected $fillable = ['title', 'content', 'user_id', 'published'];
// Обратная связь: статья принадлежит пользователю
public function author()
{
return $this->belongsTo(User::class, 'user_id');
}
// Статья имеет много комментариев
public function comments()
{
return $this->hasMany(Comment::class);
}
}
// Примеры работы с hasMany
$user = User::find(1);
// Получить все статьи
$posts = $user->posts;
// Получить только опубликованные статьи (с условием на связь)
$published = $user->posts()->where('published', true)->get();
// Получить последние 5 статей
$recent = $user->posts()->orderByDesc('created_at')->limit(5)->get();
// Добавить статью через связь (user_id заполняется автоматически)
$user->posts()->create([
'title' => 'Новая статья',
'content' => 'Текст...',
]);
belongsToMany — многие-ко-многим
Когда одна сущность может принадлежать многим другим и наоборот. Пример: статья — теги, пользователь — роли. Требует промежуточную (сводную) таблицу.
// Миграция сводной таблицы (по алфавиту: post_tag, а не tag_post)
Schema::create('post_tag', function (Blueprint $table) {
$table->foreignId('post_id')->constrained()->onDelete('cascade');
$table->foreignId('tag_id')->constrained()->onDelete('cascade');
$table->primary(['post_id', 'tag_id']);
});
// Модели
class Post extends Model
{
public function tags()
{
return $this->belongsToMany(Tag::class);
}
}
class Tag extends Model
{
public function posts()
{
return $this->belongsToMany(Post::class);
}
}
// Работа с ManyToMany
$post = Post::find(1);
// Прикрепить теги
$post->tags()->attach([1, 2, 3]);
// Открепить теги
$post->tags()->detach([2]);
// Синхронизировать (удаляет лишние, добавляет нужные)
$post->tags()->sync([1, 3, 5]);
// Получить статьи с тегом "PHP"
$phpTag = Tag::where('slug', 'php')->first();
$posts = $phpTag->posts;
// Доп. данные в сводной таблице (если есть поле sort_order)
// В миграции добавьте: $table->integer('sort_order')->default(0);
// В модели: return $this->belongsToMany(Tag::class)->withPivot('sort_order');
// Доступ: $post->tags->first()->pivot->sort_order;
Проблема N+1 и жадная загрузка (Eager Loading)
Это самая частая ошибка при работе с Eloquent. Без жадной загрузки каждый проход цикла делает отдельный SQL-запрос.
// ПЛОХО: проблема N+1
// 1 запрос — получить 100 статей
// + 100 запросов — получить автора каждой статьи
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // ← каждый раз новый SELECT
}
// ХОРОШО: жадная загрузка через with()
// 2 запроса — статьи + все авторы сразу
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name; // ← из памяти, без запроса
}
// Загрузка нескольких связей
$posts = Post::with(['author', 'tags', 'comments'])->get();
// Вложенная жадная загрузка
// Статьи + комментарии + авторы комментариев
$posts = Post::with('comments.author')->get();
// Жадная загрузка с условием
$posts = Post::with(['comments' => function ($query) {
$query->where('approved', true)
->orderByDesc('created_at');
}])->get();
// Если модель уже загружена — используем load()
$post = Post::find(1);
$post->load(['author', 'tags']);
Чтобы обнаружить N+1 в development-режиме, включите логирование запросов:
// В AppServiceProvider::boot() или прямо в маршруте/контроллере
use Illuminate\Support\Facades\DB;
DB::listen(function ($query) {
logger($query->sql, ['bindings' => $query->bindings]);
});
// Или проще — пакет barryvdh/laravel-debugbar
// composer require barryvdh/laravel-debugbar --dev
hasOneThrough и hasManyThrough — связи через посредника
Когда нужно получить данные через промежуточную модель. Например: у страны много пользователей, у пользователей много статей — нужно получить все статьи страны.
// Структура таблиц:
// countries -> users (country_id) -> posts (user_id)
class Country extends Model
{
// Все статьи пользователей страны
public function posts()
{
return $this->hasManyThrough(
Post::class, // конечная модель
User::class, // промежуточная модель
'country_id', // FK в таблице users
'user_id', // FK в таблице posts
'id', // PK в таблице countries
'id' // PK в таблице users
);
}
}
// Использование
$russia = Country::where('code', 'RU')->first();
$allPosts = $russia->posts; // все статьи российских пользователей
Полиморфные связи
Когда одна модель может принадлежать нескольким разным моделям. Пример: комментарии к статьям и комментарии к видео — одна таблица comments.
// Миграция таблицы comments
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->text('body');
$table->morphs('commentable'); // создаст commentable_id и commentable_type
$table->timestamps();
});
// Модель Comment
class Comment extends Model
{
public function commentable()
{
return $this->morphTo();
}
}
// Модели Post и Video
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');
}
}
// Использование
$post = Post::find(1);
$post->comments()->create(['body' => 'Отличная статья!']);
$video = Video::find(5);
$video->comments()->create(['body' => 'Спасибо за видео!']);
// В БД: commentable_type = "App\Models\Post", commentable_id = 1
// В БД: commentable_type = "App\Models\Video", commentable_id = 5
Подсчёт связанных записей без загрузки
Не нужно загружать все комментарии, чтобы посчитать их количество — используйте withCount.
// Получить статьи с количеством комментариев
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count; // добавляется автоматически
}
// Несколько счётчиков
$posts = Post::withCount(['comments', 'tags', 'likes'])->get();
// Счётчик с условием (только одобренные комментарии)
$posts = Post::withCount(['comments as approved_comments_count' => function ($q) {
$q->where('approved', true);
}])->get();
// Суммировать числовое поле связанной таблицы
$posts = Post::withSum('votes', 'value')->get();
// $post->votes_sum_value
Итог и чеклист
- hasOne — у модели есть одна связанная запись (пользователь → профиль)
- hasMany — у модели много связанных записей (пользователь → статьи)
- belongsTo — обратная сторона hasOne / hasMany (статья → пользователь)
- belongsToMany — много-ко-многим через сводную таблицу (статья → теги)
- hasManyThrough — связь через промежуточную модель (страна → статьи через пользователей)
- morphMany — полиморфная связь (комментарии к разным типам контента)
Всегда используйте with() при выводе списков, где обращаетесь к связанным данным — это исключит проблему N+1. Если не уверены, сколько запросов делает страница — включите debug bar или логируйте SQL в режиме разработки.
