Laravel Relationships: One-to-Many, Many-to-Many
You add a post listing page, it looks fine locally with ten rows, and then in production the page takes seconds to load. Open Debugbar and there are hundreds of nearly identical SELECT * FROM users WHERE id = ? queries. Or you save a tag form and somehow every post ends up with duplicate tags. Both problems come from the same place: not quite understanding how Eloquent relationships map to tables and queries.
This post goes through the relationship types I actually use (one-to-one, one-to-many, many-to-many, has-many-through and polymorphic), the migrations behind each, how to manage pivot tables, and how to keep eager loading under control.
You could write every join by hand. But relationship methods give you one place to define how two models connect, and from there you get eager loading, whereHas(), withCount() and pivot helpers for free. When a foreign key name changes, you fix it in one method instead of twenty queries.
Understanding the Relationship Types
Here are the six relationship types you'll run into most, and what each one needs in the database:
| 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
The usual example is a User with one Profile. You'd typically split data out like this when the extra columns are optional or rarely read, so the users table stays small.
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
This is the one you'll write most often. One parent owns many children: a User writes many Posts, a Category holds many Products.
The foreign key always lives on the "many" side. Eloquent guesses its name as the snake_case parent model name plus _id (so user_id) unless you pass a different one.
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 runs a separate SQL query for each post. That's the N+1 problem. Use eager loading (with()) whenever you fetch related models in bulk. There's more on this below.
Many-to-Many Relationship
A post has many tags, and a tag is used on many posts. Neither table can hold the foreign key, so you need a third pivot table that stores pairs of IDs.
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
The short version: attach() adds rows, detach() removes them, and sync() makes the pivot match exactly the IDs you pass. For a form with a multi-select, sync() is almost always what you want.
One gotcha: attach() doesn't check for existing rows. Call it twice with the same ID and you either get a duplicate pivot row or, with the composite primary key below, an SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry error. Use syncWithoutDetaching() when you only want to add IDs that aren't there yet.
$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
Load a collection of posts, loop over them, touch $post->user, and Eloquent quietly runs one query per post. With 20 posts nobody notices. With 500 the page crawls, and it's one of the most common slowdowns I see in Laravel apps.
Lazy loading fires N+1 queries. Eager loading one relationship fires 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
Sometimes you want records two hops away. A Country has users, users have posts, and you want all posts from one country. hasManyThrough does that in a single query with a join, so you don't have to load the 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
Say both posts and videos need comments. You could create post_comments and video_comments, but then every feature gets written twice. A polymorphic relation uses one comments table with two columns: the parent's ID and the parent's class name.
One production detail: by default commentable_type stores the full class name, like App\Models\Post. If you ever move or rename that model, old rows stop resolving. Calling Relation::enforceMorphMap() in a service provider with short aliases ('post' => Post::class) avoids that, and it's much easier to set up before the table has data in it.
// 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
Legacy databases rarely follow Laravel's naming. When they don't, pass the key names yourself:
// 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 |
Mistakes I See Most Often
Most relationship bugs I run into aren't about syntax. They're about defaults nobody thought about.
Things to check before shipping
- Lazy loading in loops: Add
Model::preventLazyLoading(! app()->isProduction());inAppServiceProvider::boot(). Locally, any accidental N+1 throws aLazyLoadingViolationExceptioninstead of silently slowing the page, while production keeps working. - Using
attach()wheresync()belongs: For a tags or roles multi-select,sync()removes, adds and keeps in one call.attach()on every save is how duplicate pivot rows happen. - Cascades you didn't mean:
->onDelete('cascade')is right for profiles and pivot rows. Onposts.user_idit means deleting a user wipes their content. If you use soft deletes, the database cascade won't fire at all, since the row isn't actually deleted. - Loading a relation just to count it:
$user->posts->count()loads every post.withCount('posts')or$user->posts()->count()asks the database instead. - Polymorphic relations everywhere: They're handy for comments, likes and images shared across models, but
commentable_idcan't have a real foreign key constraint. If only one model will ever have comments, a plainhasManywith a proper foreign key is simpler and safer. hasManyThroughfor deep chains: It handles exactly one intermediate table. For anything deeper, I'd write the join or a query scope rather than stacking relations.
Official Docs: laravel.com/docs/eloquent-relationships
Related on this blog: Laravel Eloquent ORM: Advanced Techniques
Related on this blog: Database Normalization Explained
If I had to pick one habit: define relationships for the connections your app really uses, turn on preventLazyLoading in development, and check the query count in Debugbar or Telescope on any page that lists records. That catches most problems before users do.