You can ship a small Laravel app with hasMany and belongsTo and never reach for anything else. You can also work on a five-year-old codebase where every model has a polymorphic notes(), a belongsToMany with a custom pivot class, and a hasManyThrough that nobody dares touch. Both are Laravel. The difference is what the team understood about Eloquent relationships when the schema was being drawn.

Relationships are the part of Eloquent that pays off most when you know exactly which one you are choosing and why. The names look interchangeable on a method list. The SQL they produce is not. This is a tour through the full set, the corners that catch teams out, and the guard rails that keep relations behaving in production.

The Shape Of Every Eloquent Relation

Before walking through the types, the mental model that ties them together: a relation is a method that returns a Relation object. Laravel calls that method to find out which table to join, which foreign key to match, and what to return — a single model, a collection, or a query builder. The relationship type is the strategy.

Roughly the menu looks like this:

  • hasOne, hasMany, belongsTo — the basic foreign-key trio.
  • hasOneThrough, hasManyThrough — reach across one intermediate table.
  • belongsToMany — many-to-many through a pivot table.
  • morphOne, morphMany, morphTo — polymorphic single-target.
  • morphToMany, morphedByMany — polymorphic many-to-many.

Every one of those compiles to predictable SQL. Pick the wrong one and you get the right answer with the wrong number of queries.

The Foreign-Key Trio

hasOne and hasMany live on the parent. belongsTo lives on the child:

PHP
class User extends Model
{
    public function profile() { return $this->hasOne(Profile::class); }
    public function posts()   { return $this->hasMany(Post::class); }
}

class Post extends Model
{
    public function author() { return $this->belongsTo(User::class, 'author_id'); }
}

Two things to lock in early. First, withDefault() on a belongsTo saves you from null-pointer accidents in views:

PHP
public function author()
{
    return $this->belongsTo(User::class, 'author_id')
        ->withDefault(['name' => 'Deleted user']);
}

Now $post->author->name never crashes when the user row is gone. Second, the inverse relation is worth declaring. Laravel 11.22 added chaperone() so eager-loaded children carry a back-reference to the parent without re-fetching:

PHP
class Post extends Model
{
    public function comments()
    {
        return $this->hasMany(Comment::class)->chaperone('post');
    }
}

When you eager-load comments, each comment's ->post is the parent model already in memory — no extra query.

Many-To-Many And The Pivot You Actually Need

belongsToMany is the relation that grows complications. The pivot table starts as role_user with two foreign keys, and within a year it has created_at, expires_at, assigned_by, and a metadata JSON column. The relation has to know about all of them:

PHP
class User extends Model
{
    public function roles()
    {
        return $this->belongsToMany(Role::class)
            ->withPivot('expires_at', 'assigned_by', 'metadata')
            ->withTimestamps();
    }
}

withPivot exposes the extra columns through $user->roles->first()->pivot->expires_at. withTimestamps makes Laravel maintain created_at/updated_at on the pivot row, which is the difference between knowing when a role was granted and just knowing the user has it.

The moment the pivot has behavior — methods, casts, scopes — promote it to a real model:

PHP
class RoleUser extends Pivot
{
    protected $casts = [
        'expires_at' => 'datetime',
        'metadata'   => 'array',
    ];

    public function isExpired(): bool
    {
        return $this->expires_at?->isPast() ?? false;
    }
}

// in User
public function roles()
{
    return $this->belongsToMany(Role::class)
        ->using(RoleUser::class)
        ->withPivot('expires_at', 'assigned_by', 'metadata')
        ->withTimestamps();
}

The using() call tells Eloquent to hydrate pivot rows as RoleUser instances. You get casts, methods, and the rest of the model surface on what was previously a glorified array.

A relationship-type chart showing has-one, has-many, belongs-to, has-many-through, belongs-to-many with a pivot table, and the morph family. Each block notes the SQL shape — single foreign key, intermediate join, pivot table with extra columns, or morph type plus id pair.
The full Eloquent relationship menu, each with the SQL shape that defines it.

Has-Many-Through: One Skip Hop, Two Deep

hasManyThrough is the relation people remember exists and then forget how to spell. It crosses one intermediate table to get to the rows you want:

PHP
class Country extends Model
{
    // Country has many Users; each User has many Posts.
    // Reach Posts directly from Country.
    public function posts()
    {
        return $this->hasManyThrough(Post::class, User::class);
    }
}

The query is one JOIN and a WHERE country_id = ?. No intermediate hydration of User models. It is the right fit when the middle table is a structural step, not an entity you care about in this context.

hasOneThrough is the same pattern when the chain produces exactly one result — a user has one mailing address through their organization, for example.

The Polymorphic Family

Polymorphic relations let one table point at many possible parent types. The classic case is comments that can attach to a Post, a Video, or a Product:

PHP
class Comment extends Model
{
    public function commentable()
    {
        return $this->morphTo();
    }
}

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

The comments table has commentable_id and commentable_type. The _type column stores the fully qualified class name by default, which is fragile — rename App\Models\Post to App\Domain\Blog\Post and every existing row stops resolving. The fix is to register a morph map early:

PHP
// AppServiceProvider::boot
use Illuminate\Database\Eloquent\Relations\Relation;

Relation::enforceMorphMap([
    'post'    => \App\Models\Post::class,
    'video'   => \App\Models\Video::class,
    'product' => \App\Models\Product::class,
]);

enforceMorphMap (introduced in Laravel 8) does both jobs at once: it stores friendly aliases in the database and throws if you ever pass an unmapped class. Renames stop being a 50-table migration.

morphToMany and morphedByMany extend the idea to many-to-many — tags on posts and videos, for instance, with a single taggables pivot.

A three-stage diagram of the same role_user join table evolving from a plain pivot to a standalone model. Stage 1 — Plain Pivot: just two foreign keys (user_id, role_id) used by belongsToMany('roles'). Stage 2 — Custom Pivot: same table with extra columns (granted_at, granted_by) accessed through a UserRole class extending Pivot, with withPivot() listing the columns and withTimestamps() capturing assignment time. Stage 3 — Standalone Model: the join row promoted to its own model (UserRole extends Model with hasOne for User and Role) once the relationship gains lifecycle, status, or its own events worth observing.
When the same join table grows up — three stages of role_user, and what each one buys you.

Querying Through Relations Without Loading Them

Eager loading is for reading. Filtering by relation existence is what whereHas, whereDoesntHave, and the aggregates are for:

PHP
// Users who have posted at least once in the last 30 days
User::query()
    ->whereHas('posts', fn ($q) => $q->where('created_at', '>=', now()->subDays(30)))
    ->get();

// Users with no comments at all
User::whereDoesntHave('comments')->get();

// User list with virtual counters
User::query()
    ->withCount('posts')
    ->withSum('orders as lifetime_value', 'amount')
    ->withAvg('reviews as avg_rating', 'score')
    ->paginate(25);

whereHas compiles to a correlated subquery — efficient on indexed foreign keys, very expensive without them. The aggregates (withCount, withSum, withAvg, withMin, withMax) all attach a single subquery to the SELECT, so a list page with three counters still runs one query.

Two notes that bite teams in production. whereHas ignores soft deletes on the related model unless you scope it explicitly — passing the trashed children check is not automatic. And withCount('posts') returns a string on some database drivers; cast it (->getCast() or 'posts_count' => 'int' in $casts) if you compare it with ===.

Touching Parents And Other Side Effects

Eloquent has small, useful behaviors hiding on the relation declarations.

$touches updates a parent's updated_at whenever a child changes. Useful when caching by parent timestamp:

PHP
class Comment extends Model
{
    protected $touches = ['post'];
}

Every Comment::create() now also bumps posts.updated_at. That pattern is what makes Cache::remember("post.{$post->id}.{$post->updated_at}", ...) actually invalidate when comments change.

The trade-off is that touching parents adds an UPDATE per write. On a chat-style table where comments fly in by the second, that is a lot of extra writes. Reach for it when caching benefits clearly outweigh write amplification, not by default.

A Quick Tour Of The Mistakes

A few patterns I keep seeing and reverting:

  • with('items.product.category.brand') four levels deep on a list endpoint. Each segment is a separate query batch. Either flatten what you actually need with select constraints, or replace the chain with a JOIN for the columns you display.
  • A belongsToMany whose pivot has obviously become a domain entity (subscriptions with started_at, cancelled_at, plan_id, seats) but is still pivot-shaped. That is no longer a relation — that is a Subscription model and two hasMany relations.
  • Polymorphic relations introduced for "future flexibility" that ends up storing only one parent type for two years. You are paying the indexing cost and the morph-map maintenance for nothing. A plain belongsTo is fine until the second type actually arrives.
  • Forgetting withTimestamps on a belongsToMany and then writing manual queries to recover when something was attached. The pivot timestamps cost almost nothing and are exactly what audit reports ask for later.

The relations Eloquent gives you are excellent. They reward the time you spend choosing carefully and punish the moment you wave a generic one in. When you find yourself reaching for "whatever works," that is the cue to step back and draw the four tables involved on paper. The right relation is usually obvious once you can see them.