Отношения Eloquent: связи между таблицами

Отношения Eloquent описывают связи между таблицами PHP-методами, позволяя обращаться к связанным данным как к свойствам объекта.

Суть: у пользователя много заказов (hasMany), заказ принадлежит пользователю (belongsTo), у поста много тегов и наоборот (belongsToMany). Eloquent сам строит JOIN-запросы.

В реальной базе данные связаны: у автора много статей, у заказа — много товаров, у товара — категория. В чистом SQL это означает писать JOIN-запросы вручную. Eloquent делает связи декларативными: вы описываете тип отношения методом в модели, а потом обращаетесь к связанным записям как к обычному свойству. Например, $user->orders вернёт все заказы пользователя.

Три основных типа отношений покрывают большинство случаев. Один-ко-многим: у пользователя много заказов. Обратное: заказ принадлежит пользователю. Многие-ко-многим: у поста много тегов, и каждый тег относится ко многим постам (через промежуточную таблицу).

Описание отношений

<?php
// User.php — у пользователя много заказов
class User extends Model
{
    public function orders()
    {
        return $this->hasMany(Order::class);
    }
}

// Order.php — заказ принадлежит пользователю
class Order extends Model
{
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

// Post.php — многие-ко-многим с тегами
class Post extends Model
{
    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }
}

Обращение к связанным данным выглядит как обращение к свойству:

<?php
$user = User::find(1);
foreach ($user->orders as $order) {     // все заказы пользователя
    echo $order->total;
}

$order = Order::find(5);
echo $order->user->name;                // имя владельца заказа

Как работает под капотом и проблема N+1

Связь определяется по соглашению о внешних ключах: hasMany(Order::class) ищет в таблице orders колонку user_id. При первом обращении к $user->orders Eloquent выполняет отдельный SQL-запрос (ленивая загрузка). И тут возникает классическая ловушка — проблема N+1: если в цикле по 100 пользователям обращаться к их заказам, выполнится 1 запрос на пользователей плюс 100 запросов на заказы.

  ПРОБЛЕМА N+1
  -----------
  User::all()              -> 1 запрос (100 пользователей)
  foreach -> $u->orders    -> +100 запросов!
  ИТОГО: 101 запрос (медленно)

  РЕШЕНИЕ: жадная загрузка with()
  -------------------------------
  User::with('orders')->get()
  -> 2 запроса: пользователи + все их заказы разом

Лечится жадной загрузкой with():

<?php
// вместо 101 запроса — всего 2
$users = User::with('orders')->get();

Смоделируем разницу между N+1 и жадной загрузкой на Python — считаем количество «запросов».

Попробуй сам ▶

# Сравнение: ленивая (N+1) против жадной загрузки
users  = [{'id': i, 'name': f'U{i}'} for i in range(1, 6)]
orders = {1: ['A'], 2: ['B', 'C'], 3: [], 4: ['D'], 5: ['E', 'F']}

# N+1: запрос на пользователей + по запросу на каждого
q = 1
for u in users:
    _ = orders[u['id']]   # отдельный запрос
    q += 1
print('Ленивая загрузка: запросов =', q)

# Жадная with(): 2 запроса всего
print('Жадная with():   запросов =', 2)

Частые ошибки

  • Проблема N+1. Обращение к связям в цикле без with() рождает лавину запросов и тормозит страницу.
  • Неверный внешний ключ. Если колонка называется не по соглашению, нужно указать её явно вторым аргументом.
  • Забыть промежуточную таблицу. Для belongsToMany нужна сводная таблица (например, post_tag).

Best practices

  • Всегда используйте with() для связей, к которым обращаетесь в цикле.
  • Называйте внешние ключи по соглашению (user_id) — меньше ручной настройки.
  • Используйте php artisan model:show или дебаг-панель, чтобы ловить N+1.

Отношения в Eloquent — это не только чтение связанных данных, но и удобное их создание. Метод save() на отношении автоматически проставит внешний ключ: вызов $user->orders()->create(['total' => 500]) создаст заказ и сразу привяжет его к пользователю, не заставляя вручную указывать user_id. Для связей многие-ко-многим есть методы attach(), detach() и sync(), управляющие записями в промежуточной таблице: $post->tags()->sync([1, 2, 3]) приведёт набор тегов поста ровно к указанному списку. Кроме того, отношения можно использовать как условия в запросах: User::has('orders') вернёт только пользователей с заказами, а User::whereHas('orders', fn($q) => $q->where('total', '>', 1000)) — тех, у кого есть крупные заказы. Это позволяет фильтровать по связанным данным, не выгружая их в память.

Итог: отношения Eloquent делают связи между таблицами декларативными, а жадная загрузка спасает от проблемы N+1. Дальше переходим к работе с формами и валидацией пользовательского ввода.

Проверьте себя
1. Какой метод описывает отношение «у пользователя много заказов»?
AbelongsTo
BhasMany
ChasOne
DbelongsToMany
2. Как решается проблема N+1 при обращении к связям в цикле?
AДобавить больше памяти
BИспользовать жадную загрузку with()
CУдалить отношения
DПисать сырой SQL