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 Posts — hasManyThrough 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.