Связи в 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 — «я держу чужой ключ». Модель с внешним ключом принадлежит родителю:

php
class 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 — «мой ключ лежит у них». Родитель держит коллекцию детей:

php
class 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 с единственным ребёнком:

php
class 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();
});
php
class 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

Подводный камень

  1. Направление связи определяет внешний ключ — и наоборот. belongsTo ставится у модели, в чьей таблице лежит ключ. Путаница «has many или belongs to» — почти всегда путаница «где лежит foreign key». Заказ держит user_id — заказ belongsTo пользователя, пользователь hasMany заказов.
  2. attach/sync не срабатывают на события моделей. Pivot-таблица не имеет модели по умолчанию — observer и мутаторы на неё не действуют. Нужна логика при изменении связи — своя модель для промежуточной таблицы или события pivotAttached.
  3. sync удаляет всё, чего нет в списке. sync([$id1, $id2]) отсоединит все прочие связи. Если цель «добавить, не трогая остальное» — syncWithoutDetaching() или attach(). Потерянные связи после правки метода — классическая цена невнимания.
  4. Обращение к связи на null. $order->user->email упадёт, если заказ без пользователя. Пишите $order->user?->email или проверяйте — эта ошибка всплывает на реальных данных, которых на локалке не было.
  5. Обратное имя связи должно быть уникальным. Если у модели две связи на одну и ту же таблицу (например, 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();

Три вывода

  1. Тип связи диктует внешний ключ: ключ у меня — belongsTo, ключ у них — hasMany/hasOne, промежуточная таблица — belongsToMany; имя pivot-таблицы по умолчанию — модели по алфавиту.
  2. Связи — точка управления загрузкой данных: без них нет with(), withCount() и whereHas(), и любой список с чужими данными превращается в N+1.
  3. Pivot-операции (attach, sync, detach) обходят события моделей, а sync бездумно удаляет лишнее — использовать их надо с пониманием последствий, а не как «красивую замену INSERT».

Вопрос-ловушка

Спрашивают так: «Чем hasMany отличается от belongsTo?» Ожидают не «один ко многим и наоборот», а опору на внешний ключ: belongsTo — у моей таблицы лежит чужой ключ, hasMany — у чужой лежит мой. Хитрый поворот: «Как получить поле quantity из промежуточной таблицы, если оно не читается?» Ответ — через withPivot('quantity') при объявлении связи: Eloquent по умолчанию достаёт из pivot только ключи. И второй: «Чем sync опаснее attach?» — sync приводит список к заданному и отсоединяет всё остальное; тот, кто менял связи на проде и терял данные, это знает не из документации.