Skip to main content

🔗 Model Relationships

Tables are rarely islands — users write posts, orders contain products, posts wear tags. Eloquent lets you declare these connections as methods on your models, then walk them like object properties. This lesson covers every relationship type Laravel 11 offers and the eager-loading habit that keeps them fast.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Define one-to-one, one-to-many, and their inverse belongsTo relationships
  • Model many-to-many relations, including pivot tables with extra columns
  • Use has-many-through and polymorphic relationships for advanced connections
  • Query relationships with has, whereHas, and withCount
  • Defeat the N+1 query problem with eager loading

Estimated Time: 50–60 minutes  •  Difficulty: Intermediate

Hands-on: Wire up a blog's User → Post → Comment → Tag relationship graph.

In This Lesson

Why Relationships?

Relational databases split data across tables to avoid duplication, then rejoin it with foreign keys. Eloquent relationships put an object-oriented face on those joins: instead of hand-writing SQL, you call $user->posts and get a collection of Post models back.

💡 A useful analogy: Relationships are the roads in your data's road network. Just as there are highways, one-way streets, and roundabouts, Eloquent gives you a relationship type for each connection pattern — and you pick the road that matches the traffic.
classDiagram class User { +posts() hasMany +profile() hasOne +roles() belongsToMany } class Post { +user() belongsTo +comments() hasMany +tags() belongsToMany } class Comment { +post() belongsTo +user() belongsTo } User --> Post : 1 to many User --> Profile : 1 to 1 User --> Role : many to many Post --> Comment : 1 to many Post --> Tag : many to many

Every relationship is just a method on the model that returns a relationship object. Read it as a property ($user->posts) to get results, or call it as a method ($user->posts()) to keep building a query.

One-to-One

The simplest relationship: one row links to exactly one row on the other side. A user has one profile; a customer has one primary address.

erDiagram USERS ||--|| PROFILES : has USERS { id int PK name string } PROFILES { id int PK user_id int FK bio text }

Define both sides — hasOne on the parent, belongsTo on the child. The child is whichever table holds the foreign key:

// User model (parent — no foreign key on its table)
public function profile()
{
    return $this->hasOne(Profile::class);
}

// Profile model (child — holds the user_id foreign key)
public function user()
{
    return $this->belongsTo(User::class);
}

Eloquent assumes the foreign key is user_id. Override the keys when your schema differs:

// hasOne(RelatedModel, foreignKeyOnRelated, localKeyOnThis)
return $this->hasOne(Profile::class, 'user_reference_id', 'uuid');
// Reading and creating through the relationship
$bio = $user->profile?->bio;            // null-safe if no profile yet

$user->profile()->create([
    'bio' => 'Laravel developer',
]);

One-to-Many

One row links to many rows, but each of those rows links back to just one parent. A user writes many posts; a category holds many products.

erDiagram USERS ||--o{ POSTS : writes USERS { id int PK name string } POSTS { id int PK user_id int FK title string }
// User model
public function posts()
{
    return $this->hasMany(Post::class);
}

// Post model
public function user()
{
    return $this->belongsTo(User::class);
}

The hasMany side returns a collection; the belongsTo side returns a single model. Because the relationship method is also a query builder, you can constrain it:

$posts = $user->posts;              // Collection of every post

$recent = $user->posts()            // keep chaining as a query
    ->where('published', true)
    ->latest()
    ->take(5)
    ->get();

$count = $user->posts()->count();   // efficient COUNT, no hydration

// Create a child already linked to its parent
$user->posts()->create([
    'title'   => 'Learning Eloquent Relationships',
    'content' => 'Relationships are a powerful feature...',
]);
💡 Analogy: A one-to-many relationship is a hub and spokes — one teacher, many students. Every spoke knows its hub (belongsTo), and the hub knows all its spokes (hasMany).

Many-to-Many & Pivots

Both sides can link to many of the other: a post has many tags, and a tag belongs to many posts. This needs a third pivot table to store the pairings.

erDiagram POSTS ||--o{ POST_TAG : has TAGS ||--o{ POST_TAG : has POSTS { id int PK title string } TAGS { id int PK name string } POST_TAG { post_id int FK tag_id int FK }

Both models use belongsToMany. By convention Laravel expects a pivot table named from the two singular models in alphabetical order — here, post_tag:

// Post model
public function tags()
{
    return $this->belongsToMany(Tag::class);
}

// Tag model
public function posts()
{
    return $this->belongsToMany(Post::class);
}

Manage the associations with a rich set of pivot methods:

$post->tags()->attach([1, 2, 3]);   // add pairings
$post->tags()->detach([2]);          // remove one pairing
$post->tags()->sync([1, 4, 5]);      // make the set exactly these
$post->tags()->toggle([1, 2]);       // flip membership

Pivots with extra columns

The pivot can carry its own data — an enrollment date, a grade, a role. Declare the extra columns with withPivot():

// Migration
Schema::create('course_student', function (Blueprint $table) {
    $table->id();
    $table->foreignId('course_id')->constrained();
    $table->foreignId('student_id')->constrained('users');
    $table->date('enrolled_at');
    $table->unsignedTinyInteger('grade')->nullable();
    $table->timestamps();
});

// Model
public function courses()
{
    return $this->belongsToMany(Course::class)
        ->withPivot('enrolled_at', 'grade')
        ->withTimestamps();
}

// Attach with pivot data, then read it back
$student->courses()->attach($course->id, ['enrolled_at' => now()]);

foreach ($student->courses as $course) {
    echo $course->pivot->enrolled_at;
}

📖 Key Term

Pivot (junction) table: an intermediate table whose only job is to store pairs of foreign keys — one from each side of a many-to-many relationship — plus any attributes that describe the pairing itself.

Has-Many-Through

Sometimes you want to reach a distant table through an intermediate one without a pivot. A country has many posts through its users: countries don't own posts directly, but their users do.

erDiagram COUNTRIES ||--o{ USERS : has USERS ||--o{ POSTS : writes COUNTRIES { id int PK name string } USERS { id int PK country_id int FK } POSTS { id int PK user_id int FK }
class Country extends Model
{
    // All posts written by users who belong to this country
    public function posts()
    {
        return $this->hasManyThrough(
            Post::class,   // final model we want
            User::class,   // intermediate model
            'country_id',  // FK on the intermediate (users.country_id)
            'user_id',     // FK on the final model (posts.user_id)
            'id',          // local key on this model (countries.id)
            'id'           // local key on the intermediate (users.id)
        );
    }
}

$posts = $country->posts; // every post from that country's users
💡 Analogy: A "through" relationship is an express train — it skips the intermediate station (Users) and takes you straight from Country to Posts, even though the tracks physically pass through the middle.

Polymorphic Relationships

A polymorphic relationship lets one model belong to several different parent types through a single association. Comments that can attach to both posts and videos; images that belong to users, posts, or products. The trick is two columns on the child: a *_id and a *_type that records which model it points at.

erDiagram POSTS ||--o{ COMMENTS : has VIDEOS ||--o{ COMMENTS : has COMMENTS { id int PK commentable_id int commentable_type string body text }
// Comment model — the polymorphic child
class Comment extends Model
{
    public function commentable()
    {
        return $this->morphTo();
    }
}

// Post and Video each expose the inverse
class Post extends Model
{
    public function comments()
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

class Video extends Model
{
    public function comments()
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}
$post->comments()->create(['body' => 'Great post!']);
$video->comments;                     // works the same way

$comment = Comment::find(1);
$parent = $comment->commentable;      // a Post OR a Video

⚠️ Store type maps, not class names

By default the commentable_type column stores the full class string like App\Models\Post. If you ever rename or move a model, existing rows break. Register a morph map in AppServiceProvider::boot() with Relation::enforceMorphMap([...]) so the database stores stable aliases like 'post' instead.

Polymorphic relations extend naturally to many-to-many (morphToMany / morphedByMany) for shared features like tagging across post and video types.

Querying & Eager Loading

Two skills separate slow relationship code from fast: filtering by relationship existence, and eager loading.

Existence queries

// Users who have at least one post
$users = User::has('posts')->get();

// Users with 3 or more posts
$users = User::has('posts', '>=', 3)->get();

// Users who have a published post
$users = User::whereHas('posts', fn ($q) => $q->where('published', true))->get();

// Users with no posts at all
$users = User::doesntHave('posts')->get();

// Attach a count column without loading the posts themselves
$users = User::withCount('posts')->get();
echo $users->first()->posts_count;

The N+1 problem

This is the single most common Eloquent performance bug. Loop over 100 posts, touch $post->user inside the loop, and you fire 1 query for the posts plus 100 more for the authors — 101 queries where 2 would do.

sequenceDiagram participant App participant DB Note over App,DB: Lazy loading (N+1) App->>DB: SELECT * FROM posts DB->>App: 100 posts loop each post App->>DB: SELECT * FROM users WHERE id = ? end Note over App,DB: Eager loading (2 queries) App->>DB: SELECT * FROM posts App->>DB: SELECT * FROM users WHERE id IN (...)

Eager load with with() to collapse it to two queries:

// ❌ Lazy: 1 + N queries
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->user->name;   // a fresh query every iteration
}

// ✅ Eager: 2 queries total
$posts = Post::with('user')->get();
foreach ($posts as $post) {
    echo $post->user->name;   // already loaded
}

Eager loading composes — load nested relations, constrain them, and select only the columns you need:

$posts = Post::with([
    'user:id,name',                         // only two columns
    'comments' => fn ($q) => $q->latest()->limit(5),
    'comments.user',                        // nested
])->get();

✅ Pro tip: catch N+1 automatically

In your AppServiceProvider::boot(), call Model::preventLazyLoading(! app()->isProduction());. During development Laravel will now throw an exception the moment you access an un-eager-loaded relationship, so N+1 bugs surface immediately instead of silently in production.

Hands-on Exercise

🏋️ Wire Up a Blog

Objective: Define a realistic relationship graph and query it efficiently.

Requirements:

  1. A User has many Posts; each post belongs to a user.
  2. A Post has many Comments; each comment belongs to both a post and a user.
  3. A Post and a Tag are many-to-many.
  4. Write one query that loads all published posts with their author and comment count, avoiding N+1.
💡 Hint

The comment needs two belongsTo methods (post() and user()). For the final query, combine with('user') for the author and withCount('comments') for the tally.

✅ Sample solution
class User extends Model
{
    public function posts() { return $this->hasMany(Post::class); }
}

class Post extends Model
{
    public function user()     { return $this->belongsTo(User::class); }
    public function comments() { return $this->hasMany(Comment::class); }
    public function tags()     { return $this->belongsToMany(Tag::class); }
}

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

// One efficient query — author eager loaded, comments counted
$posts = Post::where('published', true)
    ->with('user')
    ->withCount('comments')
    ->latest()
    ->get();

foreach ($posts as $post) {
    echo "{$post->title} by {$post->user->name} "
       . "({$post->comments_count} comments)";
}

🎯 Quick Quiz

Question 1: Which model holds the foreign key in a one-to-many relationship?

Question 2: What is the fix for the N+1 query problem?

Question 3: A many-to-many relationship requires what that one-to-many does not?

Summary & Quiz

🎉 Key Takeaways

  • hasOne / belongsTo model one-to-one; hasMany / belongsTo model one-to-many. The foreign key always lives on the belongsTo side.
  • belongsToMany models many-to-many through a pivot table, which can carry its own columns via withPivot().
  • hasManyThrough reaches a distant table through an intermediate; polymorphic relations let one child belong to several parent types.
  • Filter with has / whereHas; count with withCount.
  • Eager load with with() to defeat N+1 — and enable preventLazyLoading in dev to catch it automatically.

📚 Further Reading

🚀 What's Next?

You can define and connect models — next you'll interrogate them. In Querying with Eloquent you'll master where clauses, ordering, aggregates, scopes, and conditional query building.

🎉 Connected!

Your data model now mirrors the real world. Let's learn to query it with precision.