CCelestiallines.com
← Back to blog

Laravel Eloquent Relationships Explained

By Pawan Singh · Jun 2026

One-to-One: hasOne / belongsTo

The simplest relationship: each User has exactly one Profile.

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

class Profile extends Model
{
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

The foreign key (user_id) lives on the "belongs to" side — profiles, not users. Access it like a single attribute rather than a collection: $user->profile, not $user->profile()->get().

One-to-Many: hasMany / belongsTo

The most common relationship: one Author has many Post records.

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

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

Usage reads naturally in both directions: $author->posts returns a collection, $post->author returns a single model.

Many-to-Many: belongsToMany

For relationships where both sides can have many of the other — like Post and Tag — you need a pivot table (conventionally named alphabetically: post_tag):

class Post extends Model
{
    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class);
    }
}
$post->tags()->attach($tagId);
$post->tags()->sync([$tagId1, $tagId2]); // replaces the full set
$post->tags; // collection of related Tag models

Need extra data on the pivot itself (like an assigned_at timestamp)? Use withPivot() and access it via $post->tags->first()->pivot. If the pivot data is significant enough to have its own behavior (not just extra columns), promote it to a full pivot model with using() instead of leaving it as an anonymous pivot row.

Polymorphic Relationships: One Relation, Multiple Model Types

Polymorphic relations let a single relationship (like Comment) attach to more than one model type (Post or Video) without a separate pivot table per combination:

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');
    }
}

Laravel adds commentable_id and commentable_type columns to the comments table automatically (via the morphs() migration helper) to track which model and ID each comment belongs to. There's also a many-to-many polymorphic variant (morphToMany/morphedByMany) for cases like tagging where both Post and Video can share the same Tag records through one pivot table instead of one per content type.

Distant Relations: hasManyThrough

hasManyThrough reaches across an intermediate model to a relation that isn't directly related. For example, a Country has many Users, and each User has many PostshasManyThrough lets you fetch every post written by users in a country without loading the users themselves:

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

Laravel infers the foreign keys by convention (country_id on users, user_id on posts), but both can be overridden as extra arguments when your schema doesn't follow the defaults. $country->posts then returns a flat collection of Post models via a single join query, instead of looping through users manually.

Constraining and Counting Relationships

Add conditions directly onto a relationship query rather than fetching everything and filtering in PHP:

$author->posts()->where('published', true)->latest()->get();

To count related rows without loading them at all — useful for showing "12 posts" on an author listing without pulling every post into memory — use withCount():

$authors = Author::withCount('posts')->get();
$authors->first()->posts_count; // an integer, no Post models loaded

withCount can take a constraint too: withCount(['posts' => fn ($q) => $q->where('published', true)]) counts only published posts, still in a single query.

Default Models for Empty Relationships

withDefault() avoids a null-check scattered across every place that reads a possibly-missing relationship:

public function author(): BelongsTo
{
    return $this->belongsTo(Author::class)->withDefault(['name' => 'Guest Author']);
}

Now $post->author->name is always safe to call, even for a post with no author set, instead of needing $post->author?->name ?? 'Guest Author' at every call site.

The N+1 Trap and How Relationships Make It Easy to Hit

Looping over a relationship without eager loading fires one extra query per row:

// Bad: 1 query for posts + N queries for each post's author
foreach (Post::all() as $post) {
    echo $post->author->name;
}

// Good: eager load up front, 2 queries total
foreach (Post::with('author')->get() as $post) {
    echo $post->author->name;
}

Eager loading also supports nesting and constraints: Post::with(['author', 'comments' => fn ($q) => $q->latest()->limit(5)]) loads each post's author plus only its 5 most recent comments, still without N+1 queries. Enable Model::preventLazyLoading() in AppServiceProvider during local development so this throws immediately instead of silently degrading production performance once the table has real row counts.

Quick Reference

  • hasOne / belongsTo — one-to-one.
  • hasMany / belongsTo — one-to-many.
  • belongsToMany — many-to-many, via a pivot table.
  • morphMany / morphTo — polymorphic, one relation across multiple model types.
  • morphToMany / morphedByMany — polymorphic many-to-many, shared pivot across model types.
  • hasManyThrough — access a distant relation through an intermediate model.

Need help designing a Laravel data model that scales cleanly? Get in touch — I work as a freelance Laravel developer.

Comments