Laravel Relationships: One-to-Many, Many-to-Many
One of the most powerful features of Laravel's Eloquent ORM is its elegant approach to defining and working with database relationships. Whether you're connecting users to posts, posts to tags, or orders to products, Eloquent relationships let you express complex data structures in clean, readable PHP code. In this guide, we'll explore every major relationship type — from simple one-to-one links to sophisticated many-to-many pivots — with practical examples you can use immediately in real projects.
Without ORM relationships, fetching related data requires manual joins and raw SQL. Eloquent abstracts this into intuitive method calls, handles eager loading to prevent the N+1 query problem, and keeps your code maintainable as data complexity grows.
Understanding the Relationship Types
Laravel Eloquent supports six primary relationship types, each mapping to a specific database cardinality pattern:
| Relationship | Method | Example | DB Structure |
|---|---|---|---|
| One-to-One | hasOne / belongsTo |
User → Profile | Foreign key on child |
| One-to-Many | hasMany / belongsTo |
User → Posts | Foreign key on child |
| Many-to-Many | belongsToMany |
Post ↔ Tags | Pivot table |
| Has-Many-Through | hasManyThrough |
Country → Posts via Users | Intermediate model |
| Polymorphic One-to-Many | morphMany / morphTo |
Comment on Post or Video | morphable columns |
| Polymorphic Many-to-Many | morphToMany |
Tag on Post or Video | Polymorphic pivot |
Entity-relationship diagram showing hasMany and belongsToMany associations
One-to-One Relationship
A one-to-one relationship links exactly one record in a table to exactly one record in another. The classic example is a User model that has one Profile.
Setting Up the Migration
Create the profiles table with a foreign key
The child table (profiles) holds the foreign key referencing the parent (users).
Schema::create('profiles', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('bio')->nullable();
$table->string('avatar')->nullable();
$table->string('website')->nullable();
$table->timestamps();
});
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
// A user has one profile
public function profile()
{
return $this->hasOne(Profile::class);
}
}
class Profile extends Model
{
// A profile belongs to a user
public function user()
{
return $this->belongsTo(User::class);
}
}
// Access the profile from a user
$user = User::find(1);
$bio = $user->profile->bio;
// Access the user from a profile
$profile = Profile::find(1);
$name = $profile->user->name;
// Create a profile for a user
$user->profile()->create([
'bio' => 'Full Stack Developer',
'website' => 'https://example.com',
]);
One-to-Many Relationship
The one-to-many relationship is the most common pattern in web applications. A single parent record can own many child records — think a User who authors many Posts, or a Category that contains many Products.
The foreign key always lives on the "many" side of the relationship. Eloquent automatically infers the foreign key name as {model}_id unless you specify otherwise.
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('title');
$table->text('body');
$table->boolean('published')->default(false);
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
// User model
class User extends Model
{
// One user has many posts
public function posts()
{
return $this->hasMany(Post::class);
}
// Scoped: only published posts
public function publishedPosts()
{
return $this->hasMany(Post::class)
->where('published', true)
->orderBy('published_at', 'desc');
}
}
// Post model
class Post extends Model
{
// Each post belongs to one user
public function user()
{
return $this->belongsTo(User::class);
}
}
Working with One-to-Many
$user = User::find(1);
// Retrieve all posts for this user
$posts = $user->posts; // Collection
$posts = $user->posts()->get(); // Same, using query builder
// Filter inline
$recent = $user->posts()
->where('published', true)
->orderBy('created_at', 'desc')
->take(5)
->get();
// Count without loading all records
$count = $user->posts()->count();
// Create a post automatically linked to the user
$post = $user->posts()->create([
'title' => 'My New Article',
'body' => 'Content here...',
]);
// Access parent from child
$authorName = $post->user->name;
// Delete all posts for a user (cascade in PHP)
$user->posts()->delete();
Calling $post->user inside a loop triggers a separate SQL query for each post. This is the N+1 problem. Always use eager loading (with()) when fetching related models in bulk — covered in depth below.
Many-to-Many Relationship
Many-to-many relationships exist when both sides of the association can have multiple records on the other. A blog post can have many tags, and each tag can belong to many posts. This requires an intermediate pivot table that stores pairs of foreign keys.
Creating the Pivot Table
Name the pivot table alphabetically
By convention, the pivot table name is the two model names in alphabetical order, singular, separated by an underscore: post_tag.
Add extra columns to the pivot if needed
Pivot tables can store additional metadata, like when a tag was applied or the order of tags on a post.
// Tags table
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('slug')->unique();
$table->timestamps();
});
// Pivot table (alphabetical: post_tag)
Schema::create('post_tag', function (Blueprint $table) {
$table->foreignId('post_id')->constrained()->onDelete('cascade');
$table->foreignId('tag_id')->constrained()->onDelete('cascade');
$table->primary(['post_id', 'tag_id']); // Composite primary key
// Optional extra pivot columns:
// $table->integer('sort_order')->default(0);
// $table->timestamps();
});
// Post model
class Post extends Model
{
public function tags()
{
return $this->belongsToMany(Tag::class);
}
// With extra pivot columns
public function tagsWithOrder()
{
return $this->belongsToMany(Tag::class)
->withPivot('sort_order')
->orderBy('sort_order');
}
}
// Tag model
class Tag extends Model
{
public function posts()
{
return $this->belongsToMany(Post::class);
}
}
Attaching, Detaching, and Syncing
Eloquent provides three methods for managing many-to-many associations. Understanding when to use each is key to writing clean pivot table operations.
$post = Post::find(1);
// Attach: add a relationship (doesn't remove existing)
$post->tags()->attach(3); // Attach by ID
$post->tags()->attach([3, 5, 7]); // Attach multiple
// Detach: remove a relationship
$post->tags()->detach(3); // Remove one
$post->tags()->detach([3, 5]); // Remove multiple
$post->tags()->detach(); // Remove ALL tags
// Sync: replace all tags with the given set (most common)
$post->tags()->sync([1, 2, 3]); // Post now has exactly tags 1,2,3
$post->tags()->syncWithoutDetaching([4, 5]); // Add without removing
// Toggle: attach if missing, detach if present
$post->tags()->toggle([1, 3]);
// Attach with extra pivot data
$post->tags()->attach(3, ['sort_order' => 1]);
$post->tags()->sync([
1 => ['sort_order' => 1],
2 => ['sort_order' => 2],
]);
// Access pivot data on the model
foreach ($post->tagsWithOrder as $tag) {
echo $tag->name . ' - order: ' . $tag->pivot->sort_order;
}
Eager Loading: Solving the N+1 Problem
The N+1 query problem is one of the most common performance killers in ORM-based applications. It occurs when you load a collection of parent models and then access a relationship on each one inside a loop — each access fires a separate SQL query.
Lazy loading fires N+1 queries; eager loading always fires exactly 2
// BAD: N+1 problem — fires 1 + N queries
$posts = Post::all();
foreach ($posts as $post) {
echo $post->user->name; // Query per iteration!
}
// GOOD: Eager loading — always 2 queries
$posts = Post::with('user')->get();
foreach ($posts as $post) {
echo $post->user->name; // No extra query
}
// Load multiple relationships at once
$posts = Post::with(['user', 'tags', 'comments'])->get();
// Nested eager loading
$users = User::with('posts.comments')->get();
// Conditional eager loading
$posts = Post::with(['comments' => function ($query) {
$query->where('approved', true)->orderBy('created_at');
}])->get();
// Lazy eager loading (after model is already loaded)
$posts = Post::all();
$posts->load('user', 'tags');
// Count without loading records (very efficient)
$users = User::withCount('posts')->get();
echo $users[0]->posts_count;
Has-Many-Through
The hasManyThrough relationship provides a shortcut to access distant relations through an intermediate model. For example, a Country has many Posts through Users — without needing to load users first.
class Country extends Model
{
// Country → Users → Posts
public function posts()
{
return $this->hasManyThrough(
Post::class, // Final model
User::class, // Intermediate model
'country_id', // FK on users table
'user_id', // FK on posts table
'id', // Local key on countries
'id' // Local key on users
);
}
}
// Usage
$country = Country::find(1);
$allPosts = $country->posts()->latest()->get();
Polymorphic Relationships
Polymorphic relationships allow a model to belong to more than one type of model using a single association. The canonical use case is a Comment that can be attached to either a Post or a Video — without needing separate comment tables for each.
// Migration: comments table stores morphable columns
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->text('body');
$table->morphs('commentable'); // Creates commentable_id + commentable_type
$table->timestamps();
});
// Comment model
class Comment extends Model
{
public function commentable()
{
return $this->morphTo();
}
}
// Post model
class Post extends Model
{
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
}
// Video model
class Video extends Model
{
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
}
// Usage — identical API for both parent types
$post->comments()->create(['user_id' => 1, 'body' => 'Great post!']);
$video->comments()->create(['user_id' => 2, 'body' => 'Amazing video!']);
// Retrieve the parent from a comment
$parent = Comment::find(1)->commentable; // Returns Post or Video instance
Advanced Techniques
Custom Foreign Keys
Eloquent infers foreign keys by convention, but you can always override them when your database doesn't follow the standard naming pattern:
// Custom foreign key and local key
public function posts()
{
return $this->hasMany(Post::class, 'author_id', 'id');
// ^ foreign key ^ local key
}
// belongsTo with custom foreign key
public function author()
{
return $this->belongsTo(User::class, 'author_id', 'id');
}
// belongsToMany with custom pivot table and keys
public function roles()
{
return $this->belongsToMany(
Role::class,
'user_roles', // Custom pivot table name
'member_id', // FK for current model in pivot
'role_id' // FK for related model in pivot
);
}
Relationship Constraints and Scopes
// whereHas: posts that have at least one approved comment
$posts = Post::whereHas('comments', function ($query) {
$query->where('approved', true);
})->get();
// whereDoesntHave: posts with no comments
$unengaged = Post::whereDoesntHave('comments')->get();
// has: posts with at least 3 comments
$popular = Post::has('comments', '>=', 3)->get();
// withExists: add a boolean column to each result
$posts = Post::withExists('comments as has_comments')->get();
// $post->has_comments === true/false
// Querying belongsToMany with pivot conditions
$posts = Post::whereHas('tags', function ($query) {
$query->where('name', 'Laravel');
})->get();
// Select specific columns via relationship
$user = User::with('posts:id,user_id,title,published_at')->find(1);
Relationship Methods Quick Reference
| Method | Description | Returns |
|---|---|---|
get() | Execute and return all results | Collection |
first() | Return the first result | Model or null |
count() | Count without loading | int |
exists() | Check if any records exist | bool |
create([]) | Create and associate | Model |
save($model) | Save and associate existing model | bool |
attach($id) | Many-to-many: add to pivot | void |
detach($id) | Many-to-many: remove from pivot | int |
sync([]) | Many-to-many: replace all | array |
toggle([]) | Many-to-many: flip membership | array |
associate($model) | Set belongsTo foreign key | Model |
dissociate() | Unset belongsTo foreign key | Model |
Best Practices and Conclusion
Mastering Eloquent relationships transforms how you think about data modeling. Here are the key principles to carry forward:
Key Takeaways
- Always eager load: Use
with()anytime you'll access a relationship on a collection. Enable Laravel Telescope or Debugbar to spot N+1 issues in development. - Use
sync()for many-to-many forms: When a user submits a form with a multi-select (e.g., tags, roles),sync()is the right tool — it removes, adds, and keeps in one operation. - Constrain foreign keys at the DB level: Use
->constrained()->onDelete('cascade')in migrations so the database enforces referential integrity independently of your application code. - Name pivot tables alphabetically:
post_tagnottag_post. Stick to convention and you'll rarely need to pass a custom pivot name. - Use
withCount()for dashboards:User::withCount('posts')adds aposts_countattribute without loading all posts — essential for performant list views. - Consider polymorphic relations early: If two or more models share a common child type (comments, likes, tags, images), a polymorphic relationship avoids table proliferation and duplicate logic.
Official Docs: laravel.com/docs/eloquent-relationships
Related on this blog: Laravel Eloquent ORM: Advanced Techniques
Related on this blog: Database Normalization Explained
"Eloquent relationships are the secret weapon of Laravel developers. Get them right and your code becomes self-documenting, your queries become efficient, and your database design becomes obvious."
From simple one-to-one links to polymorphic many-to-many associations, Eloquent provides a consistent, expressive API that scales from prototype to production. Start with the relationships your domain actually needs, eager load aggressively, and let the pivot table conventions do the heavy lifting for you.