Связи в Eloquent: hasMany, belongsTo и другие

Рейтинг: 77.2% · 13 голосов
Курс по Laravel: маршруты, Eloquent, Blade, миграции, очереди и API. Уроки по главам с обсуждением.
Ответить
Аватара пользователя
oleg_php
Сообщения: 25
Зарегистрирован: 14 май 2026, 08:06

Связи в Eloquent: hasMany, belongsTo и другие

Сообщение oleg_php »

АкадемияLaravel с нуляГлава 6 из 18
Оглавление курса (18)
  1. Знакомство с Laravel и установка окружения
  2. Маршруты и контроллеры
  3. Blade: шаблоны и вёрстка страниц
  4. Миграции и структура базы данных
  5. Eloquent ORM: модели и CRUD
  6. Связи в Eloquent: hasMany, belongsTo и другие (вы здесь)
  7. Формы и валидация данных
  8. Аутентификация пользователей
  9. Middleware и защита маршрутов
  10. Очереди и фоновые задачи
  11. Отправка почты и уведомления
  12. Строим REST API на Laravel
  13. Авторизация: Gates и Policies
  14. Работа с файлами: загрузка, Storage, диски local и S3
  15. Тестирование: Pest и PHPUnit, фабрики, сидеры, RefreshDatabase
  16. Сервис-контейнер, провайдеры, свои artisan-команды и планировщик
  17. Деплой в продакшен и обзор современного фронтенда (Vite, Livewire, Inertia)
  18. События и слушатели, кеширование, логирование
В прошлой главе мы работали с одной моделью: создавали, читали, обновляли и удаляли записи. Но реальные данные живут не в одной таблице. У пользователя есть посты, у поста комментарии и теги. Eloquent умеет описывать эти связи прямо в моделях, и в этой главе разберём четыре основных типа плюс главную ловушку, проблему N+1.

Подготовка: внешние ключи в миграциях:

Связи опираются на внешние ключи в базе, так что начинаем с миграции (как в главе 4). Допустим, у нас блог: таблица users уже есть из коробки, добавим posts. Создаём модель сразу с миграцией:

Код: Выделить всё

php artisan make:model Post -m
В миграции описываем внешний ключ:

Код: Выделить всё

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('title');
    $table->text('body');
    $table->timestamps();
});
foreignId('user_id')->constrained() создаёт колонку и внешний ключ на таблицу users. Имя таблицы Laravel вычисляет из имени колонки: user_id значит users.id. cascadeOnDelete() удалит посты вместе с пользователем, чтобы в базе не копился мусор.

hasMany и belongsTo:

Это пара, описывающая связь "один ко многим" с двух сторон. У пользователя много постов, пост принадлежит одному пользователю:

Код: Выделить всё

// app/Models/User.php
use Illuminate\Database\Eloquent\Relations\HasMany;

public function posts(): HasMany
{
    return $this->hasMany(Post::class);
}

// app/Models/Post.php
use Illuminate\Database\Eloquent\Relations\BelongsTo;

public function user(): BelongsTo
{
    return $this->belongsTo(User::class);
}
Типы возврата (: HasMany, : BelongsTo) для работы не обязательны, но официальная документация и скелет фреймворка используют их начиная с Laravel 10, и во всех актуальных версиях это стандарт. Указывайте: IDE начнёт подсказывать методы построителя, а статический анализатор поймает опечатку в связи.

Дальше связь доступна как свойство:

Код: Выделить всё

$user = User::find(1);
foreach ($user->posts as $post) {
    echo $post->title;
}

$post = Post::find(10);
echo $post->user->name;
Обратите внимание на разницу: $user->posts (свойство) возвращает коллекцию моделей, а $user->posts() (метод) возвращает построитель запросов. Через метод удобно фильтровать и создавать:

Код: Выделить всё

$drafts = $user->posts()->where('published', false)->get();

$user->posts()->create([
    'title' => 'Новый пост',
    'body'  => 'Текст',
]);
При create() через связь Eloquent сам подставит user_id, руками его передавать не нужно. Со стороны belongsTo связь устанавливают методом associate() и разрывают dissociate():

Код: Выделить всё

$post->user()->associate($user); // проставит user_id
$post->save();

$post->user()->dissociate(); // user_id станет null
$post->save();
Оговорка: в нашей миграции user_id не nullable, так что dissociate() здесь упрётся в ограничение базы. Метод нужен для необязательных связей, где внешний ключ объявлен через nullable().

hasOne и belongsToMany:

hasOne работает как hasMany, но возвращает одну модель вместо коллекции. Классический пример: у пользователя один профиль с настройками. Объявляется так же: return $this->hasOne(Profile::class), тип возврата HasOne.

belongsToMany описывает "многие ко многим" и требует промежуточную таблицу. Пост может иметь несколько тегов, тег вешается на несколько постов. По соглашению промежуточная таблица называется из двух имён моделей в единственном числе и в алфавитном порядке: post_tag. В ней два столбца, post_id и tag_id. Отдельной модели под неё не нужно, поэтому генерируем чистую миграцию:

Код: Выделить всё

php artisan make:migration create_post_tag_table

Код: Выделить всё

Schema::create('post_tag', function (Blueprint $table) {
    $table->foreignId('post_id')->constrained()->cascadeOnDelete();
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->primary(['post_id', 'tag_id']);
});
Составной первичный ключ заодно не даст привязать один тег к посту дважды. В обеих моделях объявляем return $this->belongsToMany(Tag::class) и return $this->belongsToMany(Post::class) соответственно, тип возврата BelongsToMany. Привязывать и отвязывать записи удобно методами attach, detach и sync:

Код: Выделить всё

$post->tags()->attach([1, 3]);
$post->tags()->sync([2, 3]); // в итоге останутся ровно эти два тега
Если в промежуточной таблице нужны дополнительные колонки, скажем timestamps или позиция сортировки, добавьте их в миграцию и допишите к связи ->withTimestamps() и ->withPivot('position'). Без этого Eloquent такие колонки не выбирает и не заполняет. Читаются они потом через $post->tags[0]->pivot->position.

Проблема N+1 и жадная загрузка:

Самая дорогая ошибка новичка. Выводим список из 50 постов с именами авторов: Post::all(), затем в цикле $post->user->name. Получается 51 запрос к базе: один за постами и по одному за каждым автором на каждой итерации. Лечится методом with(), он подгрузит всех авторов одним дополнительным запросом:

Код: Выделить всё

$posts = Post::with('user')->get();
with() умеет вложенные связи через точку, а когда коллекция уже на руках, ту же работу делает load() (это называют lazy eager loading):

Код: Выделить всё

$posts = Post::with('user.profile')->get(); // авторы вместе с их профилями

$posts->load('tags'); // догрузить ещё одну связь у готовой коллекции
Если нужно только количество связанных записей, есть withCount('posts'): он добавит к модели поле posts_count, не загружая сами посты.

Чтобы N+1 не доезжал до прода, включите штатный страж, он есть с Laravel 8.43:

Код: Выделить всё

// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}
Теперь любая ленивая загрузка связи вне продакшена бросит LazyLoadingViolationException с именем модели и связи, и забытый with() всплывёт на первом же прогоне, а не из жалоб на тормоза.

Типичные грабли:

Имена внешних ключей. Eloquent угадывает ключ по имени метода: belongsTo(User::class) в методе user() ищет колонку user_id. Назовёте метод author(), и Eloquent будет искать author_id. Либо называйте колонку соответственно, либо передайте ключ вторым аргументом: belongsTo(User::class, 'user_id').

Забытый with(). Поставьте laravel-debugbar в dev-окружении и поглядывайте на счётчик запросов. Сотня запросов на простом списке почти всегда означает N+1. А preventLazyLoading() из раздела выше превратит каждое такое место в явное исключение.

Имя промежуточной таблицы. Назовёте её tag_post вместо post_tag, и при первом же обращении к связи получите QueryException вида "Base table or view not found" (в SQLite "no such table"): Eloquent построит JOIN на несуществующую таблицу post_tag. Либо соблюдайте алфавитный порядок, либо укажите имя вторым аргументом: belongsToMany(Tag::class, 'tag_post').

Массовое заполнение через связь. $user->posts()->create() уважает $fillable модели Post так же, как обычный create(). Тонкость в том, когда именно прилетит ошибка: MassAssignmentException бросается, только если модель закрыта полностью (пустой $fillable при дефолтном $guarded). Если $fillable заполнен, но какого-то поля в нём нет, Eloquent по умолчанию молча отбросит это поле, и запись уедет в базу без него. Чтобы тихие потери стали ошибками, включите рядом с preventLazyLoading() ещё Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction()), метод доступен с Laravel 9.28.

Итог:

Теперь вы умеете связывать модели: hasMany и belongsTo для "один ко многим", hasOne для "один к одному", belongsToMany для "многие ко многим". И знаете, как with(), load() и preventLazyLoading() спасают от N+1. Есть и более редкие типы вроде hasManyThrough и полиморфных связей, они строятся на тех же принципах, доберётесь по мере надобности. В следующей главе займёмся формами и валидацией, и связи пригодятся сразу: будем сохранять данные из форм в связанные модели.
👍2 ❤️2 🔥 😄 🤔3
✔ Лучший ответ сформирован автоматически — netikz
oleg_php писал(а):Назовёте метод author(), и Eloquent будет искать author_id. а на стороне User то же самое? То есть если колонка в posts называется author_id, то и hasMany надо писать как hasMany(Post::class, 'author_id'), или там ключ угадывается по-другому?
Перейти к ответу →
Аватара пользователя
sarrus
Сообщения: 2
Зарегистрирован: 15 май 2026, 22:55

Re: Связи в Eloquent: hasMany, belongsTo и другие

Сообщение sarrus »

Спасибо, наконец дошло про разницу posts и posts(). Поставил debugbar как тут советуют, на главной моего пет-проекта оказалось 134 запроса. Добавил with('user', 'tags') в контроллер, стало 4. Ощущение, как будто кеш прикрутил, а это просто один метод.
👍1 ❤️1 🔥1 😄 🤔
Аватара пользователя
netikz
Сообщения: 1
Зарегистрирован: 15 май 2026, 21:48

Re: Связи в Eloquent: hasMany, belongsTo и другие

Сообщение netikz »

✔ Лучший ответ — сформирован автоматически
oleg_php писал(а):Назовёте метод author(), и Eloquent будет искать author_id.
а на стороне User то же самое? То есть если колонка в posts называется author_id, то и hasMany надо писать как hasMany(Post::class, 'author_id'), или там ключ угадывается по-другому?
👍1 ❤️2 🔥 😄 🤔
Аватара пользователя
xncx
Сообщения: 2
Зарегистрирован: 13 май 2026, 11:24

Re: Связи в Eloquent: hasMany, belongsTo и другие

Сообщение xncx »

А чем with() отличается от load()? Видел в чужом коде $posts->load('user') уже после получения коллекции. Это то же самое или там опять N+1 под капотом?
👍 ❤️ 🔥 😄 🤔
Аватара пользователя
hyperize
Сообщения: 1
Зарегистрирован: 11 май 2026, 18:42

Re: Связи в Eloquent: hasMany, belongsTo и другие

Сообщение hyperize »

Добавлю про N+1: можно в AppServiceProvider в boot() вызвать Model::preventLazyLoading(!app()->isProduction()), тогда любая ленивая загрузка в dev сразу кидает исключение, а не ждёт, пока ты в debugbar заметишь. Мне так пару косяков отловило ещё до ревью.
👍4 ❤️ 🔥 😄 🤔
Ответить
← Предыдущая глава
Eloquent ORM: модели и CRUD
Следующая глава →
Формы и валидация данных

Все главы курса «Laravel с нуля»

Поделиться темой: ✈ Telegram VK
  • Похожие темы

Вернуться в «Laravel с нуля»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 1 гость