<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · JUNIOR ЧАСТО ПИТАЮТЬ

Які є звʼязки в Eloquent і як обрати між hasMany, belongsToMany і morph?

Тип звʼязку визначає не «логіка», а те, де лежить ключ: hasOne/hasMany — ключ у дочірній таблиці, belongsTo — у поточній, belongsToMany — у проміжній, morph* — пара колонок *_id і *_type, коли дитина належить кільком типам батьків.

Де фізично лежить зовнішній ключ у hasMany, а де в belongsTo?
У нас коментарі і до статей, і до відео — як це змоделювати?
Чому Laravel шукає таблицю post_tag, хоча в нас вона називається tags_posts?
Що поверне $post->tags, а що $post->tags()?
Eloquent звʼязки polymorphic pivot

Усі звʼязки Eloquent — це одна й та сама річ: домовленість про те, де лежить зовнішній ключ і як за ним побудувати запит. belongsTo означає «ключ у моїй таблиці»: у posts є колонка author_id, і Laravel вгадує її з назви методу — метод author() дає author_id, метод user() дав би user_id. hasOne і hasMany — дзеркальний бік: ключ лежить у чужій таблиці, і його імʼя вгадується вже з назви батьківської моделі, тобто Postcomments.post_id. Різниця між hasOne і hasMany лише в тому, чи повертається один запис, чи колекція; схема бази в обох випадках однакова. Тому питання «що обрати» майже завжди зводиться до питання «де фізично живе колонка».

belongsToMany зʼявляється тоді, коли ключ не вміщається ні в одну з двох таблиць: пост має багато тегів і тег має багато постів, тож потрібна третя, проміжна таблиця. За замовчуванням Laravel шукає її під іменем з двох імен моделей в однині, snake_case і в алфавітному порядку: post_tag, role_user. Якщо таблиця називається інакше, її передають другим аргументом. Проміжна таблиця не має власної моделі: додаткові колонки треба явно перелічити в withPivot(), інакше $tag->pivot->sort буде порожнім, а withTimestamps() вмикає created_at/updated_at у самому pivot. Для запису використовують attach() (додати), detach() (зняти) і sync() (лишити рівно передані id) — саме sync(), а не цикл з attach(), бо attach() не перевіряє наявність і спокійно створює дублікати.

Поліморфні звʼязки (morphOne, morphMany, morphTo, morphToMany) потрібні в одному конкретному випадку: коли дочірня сутність має належати кільком різним типам батьків. Класика — коментарі, лайки, вкладення, які чіпляються і до постів, і до відео. Замість двох nullable-колонок post_id і video_id зберігається пара commentable_id + commentable_type, і commentable_type каже, у якій таблиці шукати. Значення типу за замовчуванням — повне імʼя класу, а це погана ідея: перейменували модель або перенесли її в інший неймспейс — і старі рядки перестали резолвитись. Тому в AppServiceProvider::boot() одразу оголошують Relation::enforceMorphMap() з короткими аліасами; enforceMorphMap на відміну від morphMap ще й кидає виняток, коли зберігають модель, якої в мапі немає.

Ціна polymorphic — цілісність. На колонку, яка вказує то в posts, то в videos, неможливо поставити foreign key constraint, отже база не захистить вас від «висячих» рядків і не зробить каскадного видалення — це доводиться робити руками або через deleting-хук. Друга плата — читання: with('commentable') на morphTo не дає один додатковий запит, як звичайний eager loading, а групує записи за типом і робить окремий запит на кожен тип (обмежити вибірку полів можна через morphWith()). Тому поліморфний звʼязок беруть тоді, коли типів справді багато й вони ростуть; для двох стабільних варіантів дві звичайні таблиці або окремі звʼязки часто виходять простішими й швидшими.

І останнє, на чому валяться на співбесіді: $post->tags і $post->tags() — різні речі. Без дужок це властивість, яка ліниво завантажує звʼязок і повертає Collection; з дужками — обʼєкт звʼязку, тобто query builder, на якому можна далі фільтрувати ($post->tags()->where('active', true)->get()) і рахувати на боці бази ($post->comments()->count()). Саме тому $post->comments->count() — помилка продуктивності: він тягне всі коментарі в память, щоб порахувати їх у PHP, тоді як для списків є withCount('comments'), а для вже завантаженої моделі — loadCount().

final class Post extends Model
{
    // FK author_id лежить у таблиці posts; ключ угадується з назви МЕТОДУ
    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    // FK commentable_id + commentable_type лежать у comments:
    // той самий коментар може належати посту або відео
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }

    // Проміжна таблиця post_tag: два імені в однині, алфавітний порядок
    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class)
            ->withPivot('sort')   // доступно як $tag->pivot->sort
            ->withTimestamps();   // created_at/updated_at у post_tag
    }
}

// AppServiceProvider::boot(): у commentable_type лягає 'post', а не FQCN
Relation::enforceMorphMap([
    'post' => Post::class,
    'video' => Video::class,
]);

$post->tags()->sync([3, 7, 11]);              // лишає рівно ці теги
$post->tags()->syncWithoutDetaching([12]);    // додає, нічого не знімаючи

// Один запит на пости, по одному на author і tags,
// а morphTo всередині comments дасть ще по запиту на КОЖЕН тип
$posts = Post::withCount('comments')->with(['author', 'tags'])->get();
Що вибір диктує розташування зовнішнього ключа: belongsTo — FK у моїй таблиці, hasMany — FK у чужій, це два боки одного звʼязку.
Що belongsTo вгадує ключ з назви методу (`author()` → `author_id`), а hasOne/hasMany — з назви батьківської моделі (`Post` → `post_id`).
Що belongsToMany за замовчуванням шукає таблицю з двох імен моделей в однині й алфавітному порядку (`post_tag`, `role_user`), а додаткові колонки треба явно оголосити через withPivot().
Що morph зберігає тип у колонці `*_type`, тому зовнішній ключ на рівні бази неможливий, а значення типу варто фіксувати через Relation::enforceMorphMap().
Що `$post->tags` — це властивість-колекція (ліниве завантаження), а `$post->tags()` — це query builder, на якому можна далі фільтрувати й рахувати.
Брати belongsToMany там, де вистачає hasMany: створюють pivot для звʼязку «один пост — багато коментарів», хоча ключ спокійно лежить у comments.post_id.
Робити все поліморфним «про запас»: втрачається FK-констрейнт і каскадне видалення, а замість двох чесних таблиць виходить одна смітникова.
Зберігати в `commentable_type` повне імʼя класу, а потім перенести модель в інший неймспейс — половина рядків стає непридатною.
Плутати attach() і sync(): attach() у циклі створює дублікати в pivot, бо не перевіряє наявність, а sync() лишає рівно передані id.
Рахувати через `$post->comments->count()`: це витягує всі рядки в память, замість `$post->comments()->count()` або withCount('comments').
Оголошувати belongsToMany і потім дивуватись, що `$tag->pivot->sort` порожній, бо колонку не додали в withPivot().
ПОРАДА

Не переказуйте список звʼязків — покажіть, що ви думаєте схемою бази: «зовнішній ключ у мене — belongsTo, у сусіда — hasMany, у третьої таблиці — belongsToMany, а якщо той самий коментар має належати посту й відео — morphMany з morphMap». І одразу згадайте, що morph коштує втрати foreign key constraint.

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Тип звʼязку — це питання розташування ключа. У hasMany FK лежить у дочірній таблиці (comments.post_id), у belongsTo — у поточній (posts.author_id), у belongsToMany — у pivot. Поліморфний ключ вказує на різні таблиці, тому FK-констрейнт на нього поставити не можна.