Связи Eloquent: hasMany и belongsToMany без N+1 и путаницы
Как правильно объявить один-ко-многим и многие-ко-многим в Laravel, где эти связи ломают производительность и чем belongsToMany отличается от hasMany на практике.

Вы объявили hasMany и belongsToMany, всё работает — а потом в логах видите 300 SQL-запросов на страницу списка из 50 записей. Это классический N+1: связи Eloquent соблазняют простотой, но за $post->comments в цикле скрывается отдельный запрос на каждой итерации. Разберём, чем два типа связей отличаются на уровне SQL, как их правильно объявить в обе стороны и где именно они дают проблему на живом проекте. Примеры для Laravel 11.x, синтаксис актуален начиная с Laravel 8.
Два типа связей и что за ними стоит в базе
Разница между hasMany и belongsToMany — не в синтаксисе, а в структуре таблиц. Пока вы её не держите в голове, метод выбирается наугад.
Один ко многим: hasMany и belongsTo
У поста много комментариев, но каждый комментарий принадлежит ровно одному посту. Внешний ключ post_id лежит в таблице comments — на «многих». Никакой промежуточной таблицы нет.
// app/Models/Post.php
class Post extends Model
{
// Один пост -> много комментариев
public function comments()
{
return $this->hasMany(Comment::class);
}
}
// app/Models/Comment.php
class Comment extends Model
{
// Комментарий -> его пост (обратная сторона)
public function post()
{
return $this->belongsTo(Post::class);
}
}
Eloquent по умолчанию ищет ключ post_id в таблице comments и первичный ключ id в posts. Если колонка называется иначе, имена передаются вторым и третьим аргументами: $this->hasMany(Comment::class, 'article_id', 'id').
Многие ко многим: belongsToMany
У поста много тегов, у тега много постов. Внешний ключ некуда положить — ни одна сторона не «владеет» другой. Появляется промежуточная (pivot) таблица post_tag с парой колонок post_id и tag_id.
// app/Models/Post.php
public function tags()
{
return $this->belongsToMany(Tag::class);
}
// app/Models/Tag.php
public function posts()
{
return $this->belongsToMany(Post::class);
}
Eloquent по соглашению ждёт таблицу с именами моделей в алфавитном порядке через подчёркивание: post_tag, а не tag_post. Если ваша таблица называется иначе, укажите её явно вторым аргументом: $this->belongsToMany(Tag::class, 'article_tags'). Это частый источник ошибки «Base table or view not found».
Чем различаются на практике
| Признак | hasMany / belongsTo | belongsToMany |
|---|---|---|
| Промежуточная таблица | Нет | Есть (pivot) |
| Где лежит внешний ключ | В таблице «многих» | В pivot-таблице |
| Обратная связь | belongsTo | belongsToMany (симметрична) |
| Добавление записи | save() / create() | attach() / sync() |
| Данные на связи | Только в самой записи | Можно хранить в pivot |
Ключевое различие в работе — как вы добавляете связь. Для hasMany вы создаёте новую запись, привязанную к родителю:
$post->comments()->create([
'body' => 'Первый комментарий',
]);
// post_id проставится автоматически
Для belongsToMany вы связываете уже существующие записи через pivot, а не создаёте их заново:
// Привязать теги с id 1 и 5, не трогая остальные
$post->tags()->attach([1, 5]);
// Заменить весь набор тегов ровно на [1, 5]:
// всё, чего нет в массиве, будет отвязано
$post->tags()->sync([1, 5]);
// Отвязать тег 5, сам тег в таблице tags останется
$post->tags()->detach(5);
Путаница attach и sync — самая дорогая ошибка в этой теме. attach добавляет, sync приводит набор к точному состоянию и молча удаляет лишние строки из pivot. Форма редактирования, где вы вместо sync вызвали attach в цикле, за пару недель накапливает дубликаты в промежуточной таблице.
Данные на промежуточной таблице
У belongsToMany есть возможность, которой нет у hasMany: хранить атрибуты на самой связи. Классический пример — роль пользователя в проекте: один и тот же человек может быть админом в одном проекте и наблюдателем в другом.
public function projects()
{
return $this->belongsToMany(Project::class)
->withPivot('role') // читаем колонку role из pivot
->withTimestamps(); // pivot получит created_at/updated_at
}
// Запись со значением pivot
$user->projects()->attach($projectId, ['role' => 'admin']);
// Чтение
foreach ($user->projects as $project) {
echo $project->pivot->role;
}
Колонка, которую вы читаете через $project->pivot->role, обязана быть перечислена в withPivot, иначе она просто не попадёт в результат — без ошибки, а это хуже, чем с ошибкой.
Где это ломается: N+1
Обе связи одинаково подвержены проблеме N+1. Ленивая загрузка означает, что связанные данные подтягиваются в момент обращения, а не заранее.
// ПЛОХО: 1 запрос на посты + по одному на комментарии каждого поста
$posts = Post::all();
foreach ($posts as $post) {
echo $post->comments->count(); // отдельный SELECT на каждой итерации
}
Пятьдесят постов — пятьдесят один запрос. Решение — жадная загрузка через with: Eloquent соберёт все post_id и вытянет комментарии одним запросом с WHERE post_id IN (...).
// ХОРОШО: 2 запроса независимо от числа постов
$posts = Post::with('comments')->get();
foreach ($posts as $post) {
echo $post->comments->count();
}
Для belongsToMany схема та же, но запросов под капотом три: посты, pivot и сами теги. Post::with('tags')->get() всё равно устраняет N+1 — важно, что число запросов не растёт с числом постов.
Правило простое: обращение к связи внутри цикла — почти всегда сигнал добавить with. Если связь вам нужна не всегда, а только на части страниц, оставьте ленивую загрузку, но проверьте её в логах запросов, а не на глаз.
Как поймать N+1, а не искать глазами
- Включите логирование запросов на дев-окружении и смотрите их число на типовых страницах.
- Laravel умеет сам падать при ленивой загрузке — вызовите
Model::preventLazyLoading()вAppServiceProvider(в блоке для! app()->isProduction()). Тогда любое необъявленное обращение к связи бросит исключение прямо в разработке. - Для подсчёта без загрузки самих записей используйте
withCount('comments')— получите атрибутcomments_countодним запросом, без вытягивания всех комментариев в память.
Типичные ошибки
- Имя pivot-таблицы не по соглашению. Eloquent ждёт
post_tag(алфавитный порядок), а у васtags_posts. Либо переименуйте таблицу, либо укажите имя вторым аргументом. - Забытый
withPivot. Колонка есть в таблице, но$model->pivot->fieldвозвращает null — потому что вы не перечислили поле в связи. - attach вместо sync на редактировании. Дубликаты в pivot и «залипшие» старые связи, которые пользователь снял в форме.
- Массовое удаление без каскада. Удаляя пост через
delete(), вы не удаляете строки изpost_tagавтоматически — либо настройте внешние ключи сonDelete('cascade')в миграции, либо чистите pivot вручную.
Что дальше
Когда базовые связи работают, следующий шаг — научиться фильтровать по ним без выгрузки данных в PHP: Post::has('comments') вернёт только посты с комментариями, а whereHas добавит условие на связанные записи. Отдельная большая тема — hasManyThrough и полиморфные связи (morphMany), когда одна модель комментариев обслуживает и посты, и товары. В проде первым делом настройте preventLazyLoading вне продакшена и мониторьте число SQL-запросов на ключевых страницах — это дешевле, чем ловить деградацию после релиза.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Laravel 11.x — Eloquent: Relationships
Частые вопросы
А если у меня Laravel 8 или 9, синтаксис отличается?
Чем attach отличается от sync, если оба добавляют связи?
Почему $model->pivot->role возвращает null, хотя колонка в таблице есть?
with не сработал — запросов всё равно много, что проверить?
Что произойдёт с pivot-таблицей при удалении родительской записи?
Как посчитать связанные записи, не загружая их все в память?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.