Связи в Eloquent: hasMany, belongsTo, belongsToMany
Связи в Eloquent — это описание отношений между таблицами в коде модели: $order->user и $order->items работают не магией, а по внешним ключам, которые вы (или миграция) задали в базе. Три основные связи закрывают 90% схем: belongsTo — «этот объект принадлежит другому» (заказ принадлежит пользователю), hasMany — «у этого объекта много других» (у пользователя много заказов), belongsToMany — «многие ко многим через промежуточную таблицу» (заказы и товары). Понимать связи нужно не на уровне «скопировал из доки»: от них зависит, как пишутся запросы, как строится eager loading и почему обращение к связи в цикле роняет базу.
Зачем это нужно
Боль из практики: в проекте каталога выводили список товаров с категориями «в лоб» — отдельным запросом на каждый товар:
php// ПЛОХО: связь не объявлена — фильтруем руками и лезем в базу из цикла
$products = Product::all();
foreach ($products as $product) {
// 1 запрос на КАЖДЫЙ товар: у 200 товаров — 200 запросов
$category = Category::find($product->category_id);
echo $product->name . ' — ' . ($category->name ?? 'без категории');
}
Работало — пока товаров было мало. После наполнения каталога страница отдавалась четыре секунды: 1 запрос на список + по одному на категорию каждого товара. Это классический N+1, и его причина — на другом уровне: у модели не объявлена связь, поэтому и подменить загрузку нечем:
php// ХОРОШО: связь объявлена — загрузка категорий одним запросом
class Product extends Model
{
public function category()
{
return $this->belongsTo(Category::class);
}
}
$products = Product::with('category')->get(); // 2 запроса вместо 201
foreach ($products as $product) {
echo $product->name . ' — ' . $product->category?->name;
}
Связи — это не «синтаксический сахар для find()», а точка, через которую потом управляется вся загрузка данных: with(), load(), жадная или ленивая загрузка, счётчики. Без объявленных связей этих точек просто нет.
Как это работает
Связи объявляются методами модели. Каждая связь опирается на внешний ключ — от того, где он лежит, зависит тип связи:
php// Миграция: внешний ключ лежит в таблице orders
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained(); // orders.user_id → users.id
$table->timestamps();
});
belongsTo — «я держу чужой ключ». Модель с внешним ключом принадлежит родителю:
phpclass Order extends Model
{
public function user()
{
return $this->belongsTo(User::class);
// по умолчанию: orders.user_id → users.id
// нестандартный ключ: belongsTo(User::class, 'buyer_id')
}
}
$order->user; // объект User (ленивая загрузка — один запрос при первом обращении)
$order->user()->get(); // то же самое, но через построитель — редко нужно
hasMany — «мой ключ лежит у них». Родитель держит коллекцию детей:
phpclass User extends Model
{
public function orders()
{
return $this->hasMany(Order::class);
// ищет orders.user_id = users.id
}
}
$user->orders; // коллекция Order
$user->orders()->where('status', 'paid')->get(); // запрос с дополнительным условием
$user->orders()->count(); // SELECT COUNT(*) — без загрузки моделей
Пара belongsTo + hasMany — зеркальное описание одной и той же связи: внешний ключ один, объявлений два, у каждой стороны своё.
hasOne — частный случай hasMany с единственным ребёнком:
phpclass User extends Model
{
public function phone()
{
return $this->hasOne(Phone::class); // phones.user_id, ждём одну запись
}
}
$user->phone->number; // null, если записи нет — безопасно через ?->
belongsToMany — многие ко многим через промежуточную таблицу:
php// Миграция pivot-таблицы: ключи ОБЕИХ сторон, имя по алфавиту
Schema::create('order_product', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained();
$table->foreignId('product_id')->constrained();
$table->integer('quantity')->default(1); // дополнительные поля связи
$table->timestamps();
});
phpclass Order extends Model
{
public function products()
{
return $this->belongsToMany(Product::class)
->withPivot('quantity') // дополнительные поля доступны как $item->pivot->quantity
->withTimestamps(); // иначе created_at/updated_at pivot не заполняются
}
}
class Product extends Model
{
public function orders()
{
return $this->belongsToMany(Order::class);
}
}
php// Работа с pivot-таблицей — методы связи, а не ручные INSERT
$order->products()->attach($productId, ['quantity' => 2]); // добавить связь
$order->products()->detach($productId); // убрать связь
$order->products()->sync([$p1 => ['quantity' => 3], $p2]); // привести к списку
$order->products; // коллекция Product
$order->products[0]->pivot->quantity; // поле промежуточной таблицы
Соглашение об именахpivot-таблицы: модели в алфавитном порядке, в единственном числе — order_product. Нестандартное имя — второй аргумент: belongsToMany(Product::class, 'order_items').
Вложенные связи — то, ради чего всё затевалось:
php$user->orders[0]->items[0]->product->name;
// без подготовки — каскад запросов: 1 (пользователь) + N (заказы) + ...
$users = User::with('orders.items.product')->get();
// eager loading: по одному запросу на каждый уровень — 4 запроса всего
Как это выглядит в Laravel
- Обратная связь через
belongsToна pivot — если нужен доступ от промежуточной записи к родителю, используютbelongsToThrough(пакет) или явные методы. - Счётчики без загрузки данных:
withCount('orders')добавляет полеorders_countодним подзапросом — вместо загрузки всей коллекции радиcount().
php$users = User::withCount('orders')->orderByDesc('orders_count')->limit(10)->get();
echo $users[0]->orders_count; // топ-10 покупателей без загрузки заказов
whereHas— фильтрация по существованию связи, не JOIN-ом вручную:
php// Пользователи, у которых есть оплаченный заказ
$buyers = User::whereHas('orders', fn ($q) => $q->where('status', 'paid'))->get();
has()через роуты и контроллеры не проходит — связи живут в моделях; если метод связи пишется с логикой (where,orderBy), это уже отдельный scope или метод запроса.
Плохо / Хорошо
| Плохо | Хорошо |
|---|---|
Category::find($product->category_id) в цикле | Связь belongsTo + with('category') |
| Ручные INSERT в pivot-таблицу | attach() / sync() / detach() |
withPivot забыли — «поле quantity пустое» | Дополнительные поля объявлять явно |
Pivot без withTimestamps, даты пустые | withTimestamps(), если даты нужны |
$user->orders()->where(...)->first() для «проверить, есть ли» | $user->orders()->exists() или whereHas |
| Связь объявлена только с одной стороны | Обе стороны: hasMany + belongsTo |
Подводный камень
- Направление связи определяет внешний ключ — и наоборот.
belongsToставится у модели, в чьей таблице лежит ключ. Путаница «has many или belongs to» — почти всегда путаница «где лежит foreign key». Заказ держитuser_id— заказbelongsToпользователя, пользовательhasManyзаказов. attach/syncне срабатывают на события моделей. Pivot-таблица не имеет модели по умолчанию — observer и мутаторы на неё не действуют. Нужна логика при изменении связи — своя модель для промежуточной таблицы или событияpivotAttached.syncудаляет всё, чего нет в списке.sync([$id1, $id2])отсоединит все прочие связи. Если цель «добавить, не трогая остальное» —syncWithoutDetaching()илиattach(). Потерянные связи после правки метода — классическая цена невнимания.- Обращение к связи на null.
$order->user->emailупадёт, если заказ без пользователя. Пишите$order->user?->emailили проверяйте — эта ошибка всплывает на реальных данных, которых на локалке не было. - Обратное имя связи должно быть уникальным. Если у модели две связи на одну и ту же таблицу (например,
ownerиmanager— оба users), Eloquent по умолчанию угадает неправильно — указывайте явно:belongsTo(User::class, 'owner_id')и, для обратной стороны,hasMany(Order::class, 'owner_id').
Мини-шпаргалка
php// Объявление связей
public function user() { return $this->belongsTo(User::class); } // ключ у меня
public function orders() { return $this->hasMany(Order::class); } // ключ у них
public function phone() { return $this->hasOne(Phone::class); } // один у них
public function products() { return $this->belongsToMany(Product::class)
->withPivot('quantity')->withTimestamps(); }
// Использование
$order->user?->email; // belongsTo, null-safe
$user->orders()->where('status', 'paid')->get(); // запрос по связи
$user->orders()->exists();
// Pivot
$order->products()->attach($id, ['quantity' => 2]);
$order->products()->syncWithoutDetaching([$id]);
$order->products[0]->pivot->quantity;
// Загрузка и счётчики
Product::with('category')->get();
User::withCount('orders')->get();
User::whereHas('orders', fn ($q) => $q->where('status', 'paid'))->get();
Три вывода
- Тип связи диктует внешний ключ: ключ у меня —
belongsTo, ключ у них —hasMany/hasOne, промежуточная таблица —belongsToMany; имя pivot-таблицы по умолчанию — модели по алфавиту. - Связи — точка управления загрузкой данных: без них нет
with(),withCount()иwhereHas(), и любой список с чужими данными превращается в N+1. - Pivot-операции (
attach,sync,detach) обходят события моделей, аsyncбездумно удаляет лишнее — использовать их надо с пониманием последствий, а не как «красивую замену INSERT».
Вопрос-ловушка
Спрашивают так: «Чем hasMany отличается от belongsTo?» Ожидают не «один ко многим и наоборот», а опору на внешний ключ: belongsTo — у моей таблицы лежит чужой ключ, hasMany — у чужой лежит мой. Хитрый поворот: «Как получить поле quantity из промежуточной таблицы, если оно не читается?» Ответ — через withPivot('quantity') при объявлении связи: Eloquent по умолчанию достаёт из pivot только ключи. И второй: «Чем sync опаснее attach?» — sync приводит список к заданному и отсоединяет всё остальное; тот, кто менял связи на проде и терял данные, это знает не из документации.