All articles

Eloquent relationships explained visually

Every Eloquent relationship type โ€” what it looks like on disk, where it goes wrong, and when it's the right call. Written from the perspective of someone who's debugged enough of them.

ยท 6 min read ยท By Maulik Raval

Eloquent makes it easy to declare a relationship in one line. It makes it harder to undo one. Every method you reach for โ€” hasMany, belongsToMany, morphTo โ€” locks in a real shape your database has to support and a query pattern you'll be paying off for years.

We've spent the last year building a tool that draws these relationships, which means we've also spent the last year debugging them. This is a visual reference for every type โ€” what it looks like on disk, where it tends to go wrong, and when it's actually the right call.

hasOne

One row, one related row

A parent has exactly one child. The FK lives on the child. The textbook example is a User with one Profile.

The mistake nobody catches in review: most "hasOne" tables shouldn't exist. If your profiles table is just three nullable columns the users table would have happily held, you've added a join cost for nothing. Reach for hasOne when the child has its own access pattern โ€” loaded only on profile pages, mutated independently, or carries fields heavy enough that you don't want them hydrated on every User::find().

users id bigIncrements name string email string password string timestamps auto profiles id bigIncrements user_id FK bigInteger bio text avatar_url string timestamps auto hasOne
class User extends Model
{
    public function profile(): HasOne
    {
        return $this->hasOne(Profile::class);
    }
}

Use it when: the child has a distinct lifecycle, distinct access pattern, or contains data you don't want on every parent query. Skip it when: it's "a few extra columns" dressed up as a separate table.

hasMany

One row, many related rows

The most common relationship in any application โ€” Post has many Comments, Order has many LineItems. FK on the child, as always.

The bill you'll eventually pay: N+1. A Blade @foreach over $posts that calls $post->comments->count() inside the loop runs one query per post โ€” invisible in dev, brutal in production. ->with('comments') fixes it; ->withCount('comments') is better when you only need the number. Make it a habit before the audit forces it on you.

posts id bigIncrements title string body text published_at timestamp timestamps auto comments id bigIncrements post_id FK bigInteger author string body text timestamps auto hasMany
class Post extends Model
{
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }
}

Use it when: a parent owns a list of identically-shaped children. Always: eager-load when you'll iterate. Reach for withCount instead of ->comments->count() the moment you just need the number.

belongsTo

The inverse โ€” same FK, viewed from the child

Same physical schema as hasMany. No new tables, no new FKs. The relationship method exists so you can write $comment->post->title instead of Post::find($comment->post_id)->title scattered across every view.

Always declare both halves of the relationship โ€” the hasMany on the parent and the belongsTo on the child. They cost nothing and make the relationship discoverable from either end. Future-you will thank you the next time you grep for "all the places that touch posts".

class Comment extends Model
{
    public function post(): BelongsTo
    {
        return $this->belongsTo(Post::class);
    }
}

Always: pair it with the matching hasOne/hasMany. Watch out for: nullable FKs โ€” Laravel will silently return null from the relationship and your ->title chain blows up.

belongsToMany

Many-to-many, via a pivot table

Both sides have many of each other. You can't store that without a third "pivot" table holding one row per pair. User belongsToMany Role; the pivot is role_user.

The trap people fall into: treating the pivot as plumbing. The moment your pivot grows a column of its own โ€” a granted_at timestamp, a priority score, a revoked flag โ€” it's a real entity. Promote it to a model with using() and remember to add withPivot() for every extra column you want to read.

Two methods that have ruined someone's afternoon: ->sync([...]) silently deletes any pivot row not in the new array, and ->detach() with no arguments removes every assignment. Correct semantically โ€” wrong if your pivot was carrying real data.

users id bigIncrements name string email string password string timestamps auto role_user PIVOT user_id FK bigInteger role_id FK bigInteger granted_at timestamp revoked boolean timestamps auto roles id bigIncrements name string slug string description text timestamps auto belongsToMany
class User extends Model
{
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)
            ->withPivot('granted_at')
            ->withTimestamps();
    }
}

Use it when: any flexible mapping โ€” tags, roles, categorizations, follows. Promote the pivot to a model the moment it has columns beyond the two FKs. Be careful with: sync() and bare detach() โ€” both will delete data you may not expect them to.

morphTo / morphMany

One child shape, many possible parents

When the same shape of child belongs to multiple kinds of parent โ€” comments on both posts AND videos, likes on anything, audit logs across every model โ€” polymorphic relations let you store the parent's ID and its type in a single column pair.

Honest take: this is the relationship that gets people in the most trouble at scale. You give up referential integrity (no FK constraint can span multiple parent tables), you give up easy joins, and the commentable_type column becomes a magic string the database can't validate. The flexibility is real, but rename a class and you'll spend an afternoon backfilling production rows. Reach for it only when the alternative โ€” two or three near-duplicate tables โ€” is genuinely worse.

ANY COMMENTABLE MODEL posts id bigIncrements title string timestamps auto videos id bigIncrements title string timestamps auto comments id bigIncrements commentable_id MORPH bigInteger commentable_type MORPH string body text author string timestamps auto morphMany
class Comment extends Model
{
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }
}

class Post extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

Use it when: the same child shape genuinely applies across multiple parent types AND you can live without DB-level referential integrity. Skip it when: you're using it to avoid creating a second table. Two specific tables age much better than one polymorphic table that secretly serves twelve.

hasManyThrough

Read your grandchildren in one query

Skip a hop. A Country has many Users, each User has many Posts, and you want all posts from a country. No new schema; it's a query shortcut over two existing relationships.

The honest take: if you find yourself reaching for hasManyThrough more than once or twice in a project, the intermediate model is probably hiding a relationship that wants to be promoted. It's a smell, not a sin โ€” but a smell.

class Country extends Model
{
    public function posts(): HasManyThrough
    {
        return $this->hasManyThrough(Post::class, User::class);
    }
}

Use it when: the intermediate model is genuinely transparent infrastructure. Skip it when: the intermediate hop has business meaning of its own โ€” promote it to a real relationship instead.

Cheat sheet

For when you're skimming this in the middle of a code review.

Method FK lives on Shape
hasOne child table 1 โ†” 1
hasMany child table 1 โ†” many
belongsTo this table many โ†” 1
belongsToMany pivot table many โ†” many
morphTo / morphMany child + type column many parent types
hasManyThrough intermediate child grandchild query

Explore these visually

See how LaraSchema models these relationships

Every relationship type in this post has a canonical shape on a canvas โ€” hasMany as a 1-to-many edge, belongsToMany as a pivot box between two tables, polymorphic as a typed connector. Open one of the public templates and see the same diagrams rendered against real schemas.

More articles