Why Relationships Matter

Almost every table in a real application points at another one. A post has an author, an order has line items, a user has a profile. Without an ORM you express those links as joins written by hand in every query. Eloquent lets you declare the link once on the model and then walk it like a normal PHP property.

The payoff is not just shorter code. Once a relationship is declared you also get eager loading, relationship-aware queries such as whereHas(), automatic foreign key handling on inserts, and cascading operations — none of which you get from a hand-written join.

All examples here use Laravel’s default conventions. Eloquent guesses foreign keys from the model name (Useruser_id) and the local key from the primary key (id). Every relationship method accepts explicit key names as extra arguments when your schema does not follow the convention.

One to One — hasOne / belongsTo

Use one-to-one when a row owns exactly one row in another table. The classic case is a users table with an optional profiles table holding fields you do not want bloating the users row.

// Migration
Schema::create('profiles', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('avatar')->nullable();
    $table->text('bio')->nullable();
    $table->timestamps();
});
// app/Models/User.php
public function profile()
{
    return $this->hasOne(Profile::class);
}

// app/Models/Profile.php
public function user()
{
    return $this->belongsTo(User::class);
}

The foreign key lives on the table that belongsTo. That single rule decides which side gets hasOne and which gets belongsTo, and it is the thing beginners get backwards most often.

$user = User::find(1);

$user->profile;        // Profile model (or null) — lazy loaded, runs a query
$user->profile->bio;   // careful: null-safe operator is safer
$user->profile?->bio;  // PHP 8 null-safe — returns null instead of throwing

// Creating through the relationship sets user_id for you
$user->profile()->create(['bio' => 'Laravel developer']);

Note the difference between $user->profile (the property, which returns the related model) and $user->profile() (the method, which returns a query builder you can keep chaining onto).

One to Many — hasMany

One to many is the most common relationship in any application: one post has many comments, one category has many products, one user has many orders.

// app/Models/Post.php
public function comments()
{
    return $this->hasMany(Comment::class);
}

$post = Post::find(1);

$post->comments;              // Collection of Comment models
$post->comments->count();     // counts an already-loaded collection in PHP
$post->comments()->count();   // runs SELECT COUNT(*) in SQL — cheaper

// Constrain the relationship like any query builder
$recent = $post->comments()
    ->where('approved', true)
    ->latest()
    ->take(5)
    ->get();

Because the relationship method returns a query builder, everything you know about the query builder still applies — where, orderBy, paginate, aggregate functions, the lot.

The Inverse — belongsTo

Every hasMany normally has a matching belongsTo on the other model. Declaring both is optional but almost always worth it.

// app/Models/Comment.php
public function post()
{
    return $this->belongsTo(Post::class);
}

$comment = Comment::find(1);
$comment->post->title;

// Associate / dissociate instead of setting the FK by hand
$comment->post()->associate($post);
$comment->save();

$comment->post()->dissociate();
$comment->save();

Watch out: belongsTo guesses the foreign key from the method name, not the model name. A method called author() that returns belongsTo(User::class) will look for an author_id column. If your column is user_id, pass it explicitly: belongsTo(User::class, 'user_id').

Many to Many — belongsToMany

Many to many needs a third table — the pivot table. A post has many tags, and a tag belongs to many posts. Laravel expects the pivot table to be named after the two models in singular, alphabetical order: post_tag.

Schema::create('post_tag', function (Blueprint $table) {
    $table->id();
    $table->foreignId('post_id')->constrained()->cascadeOnDelete();
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->timestamps();
    $table->unique(['post_id', 'tag_id']);
});
// app/Models/Post.php
public function tags()
{
    return $this->belongsToMany(Tag::class);
}

// app/Models/Tag.php
public function posts()
{
    return $this->belongsToMany(Post::class);
}

Attaching and detaching is where belongsToMany earns its keep:

$post->tags()->attach($tagId);            // add one
$post->tags()->attach([1, 2, 3]);         // add several
$post->tags()->detach($tagId);            // remove one
$post->tags()->detach();                  // remove all

// sync() makes the pivot match the array exactly —
// it attaches what is missing and detaches what is not listed
$post->tags()->sync([1, 2, 3]);

// syncWithoutDetaching() only ever adds
$post->tags()->syncWithoutDetaching([4]);

// toggle() flips each id
$post->tags()->toggle([1, 5]);

sync() is what you want behind a multi-select form field. Pass the submitted ids and Laravel works out the inserts and deletes for you — no diffing required.

Working With Pivot Data

Pivot tables often carry extra columns of their own — a role on a team membership, a quantity on an order line, a position for manual ordering. Declare them with withPivot().

public function users()
{
    return $this->belongsToMany(User::class)
        ->withPivot('role', 'joined_at')
        ->withTimestamps();
}

foreach ($team->users as $user) {
    echo $user->pivot->role;
    echo $user->pivot->joined_at;
}

// Set pivot values when attaching
$team->users()->attach($userId, ['role' => 'admin']);

// Or as the second element when syncing
$team->users()->sync([
    1 => ['role' => 'admin'],
    2 => ['role' => 'member'],
]);

// Filter on a pivot column
$admins = $team->users()->wherePivot('role', 'admin')->get();

Has Many Through

hasManyThrough reaches across an intermediate model. A country has many posts through users: the country has users, and each of those users has posts.

// countries -> users (country_id) -> posts (user_id)

// app/Models/Country.php
public function posts()
{
    return $this->hasManyThrough(Post::class, User::class);
}

$country->posts;   // every post written by any user in that country

The argument order reads as “the model I finally want”, then “the model I go through”. Laravel resolves the two foreign keys by convention, but you can supply them explicitly when the schema differs.

There is a hasOneThrough variant too, for when the far side is a single record — for example a supplier that has one account through a single user.

Polymorphic Relationships

A polymorphic relationship lets one model belong to more than one type of parent. Comments on both posts and videos, images attached to anything, activity log entries pointing at any model — all classic cases.

Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->text('body');
    $table->morphs('commentable');  // adds commentable_id + commentable_type
    $table->timestamps();
});
// app/Models/Comment.php
public function commentable()
{
    return $this->morphTo();
}

// app/Models/Post.php  and  app/Models/Video.php
public function comments()
{
    return $this->morphMany(Comment::class, 'commentable');
}

$post->comments;          // comments where commentable_type = App\Models\Post
$comment->commentable;    // the Post or Video it belongs to

commentable_type stores the fully qualified class name by default. That couples your database rows to your PHP namespace, which hurts the day you rename or move a model. Register a morph map in a service provider to store short aliases instead:

// app/Providers/AppServiceProvider.php — boot()
use Illuminate\Database\Eloquent\Relations\Relation;

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

There is also morphToMany for the many-to-many flavour — the standard example being tags that can be applied to posts, videos and anything else.

Querying Relationships

The real power shows up when you filter parents by their children without writing a join.

// Posts that have at least one comment
Post::has('comments')->get();

// Posts with three or more comments
Post::has('comments', '>=', 3)->get();

// Posts with at least one approved comment
Post::whereHas('comments', function ($q) {
    $q->where('approved', true);
})->get();

// The opposite
Post::doesntHave('comments')->get();
Post::whereDoesntHave('comments', fn ($q) => $q->where('spam', true))->get();

// Count without loading the rows
$posts = Post::withCount('comments')->get();
$posts->first()->comments_count;

// Aggregate a related column
Post::withSum('comments', 'score')->get();
Post::withAvg('comments', 'rating')->get();

// Eager load to avoid N+1
$posts = Post::with(['author', 'comments.author'])->get();

Looping over $posts and touching $post->comments inside the loop without with() fires one extra query per post. That is the N+1 problem — the most common performance bug in Laravel applications. It has its own article linked at the bottom of this page.

Cheat Sheet

RelationshipMethodWhere the FK livesExample
One to onehasOneRelated tableUser has one Profile
One to one (inverse)belongsToThis tableProfile belongs to User
One to manyhasManyRelated tablePost has many Comments
One to many (inverse)belongsToThis tableComment belongs to Post
Many to manybelongsToManyPivot tablePost has many Tags
ThroughhasManyThroughIntermediate + far tableCountry has Posts through Users
Polymorphic one to manymorphMany / morphToRelated table (+ type)Post and Video both have Comments
Polymorphic many to manymorphToManyPivot table (+ type)Tags on anything

The one rule to remember: the model holding the foreign key column is always the one using belongsTo. Work out where the column lives and the correct method picks itself.