Backend

Laravel Relationships: One-to-Many, Many-to-Many

Mayur Dabhi
Mayur Dabhi
April 21, 2026
14 min read

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.

Why Eloquent Relationships Matter

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
users 🔑 id name email ... posts 🔑 id 🔗 user_id title body ... tags 🔑 id name post_tag (pivot) 🔗 post_id 🔗 tag_id created_at (opt) 1 N N:M via 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

1

Create the profiles table with a foreign key

The child table (profiles) holds the foreign key referencing the parent (users).

database/migrations/create_profiles_table.php
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();
});
app/Models/User.php
<?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);
    }
}
Usage Examples
// 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.

database/migrations/create_posts_table.php
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();
});
app/Models/User.php & Post.php
// 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

CRUD Operations via Relationship
$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();
Watch Out for N+1 Queries

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

1

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.

2

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.

database/migrations/create_post_tag_table.php
// 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();
});
app/Models/Post.php & Tag.php
// 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.

Managing Pivot Relationships
$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.

N+1 (BAD) SELECT * FROM posts Then for each post... SELECT * FROM users WHERE id = 1 SELECT * FROM users WHERE id = 2 SELECT * FROM users WHERE id = 3 ... repeated N times N+1 total queries Eager Loading (GOOD) SELECT * FROM posts SELECT * FROM users WHERE id IN (1,2,3...) Eloquent maps results in memory No additional queries needed Always 2 queries total

Lazy loading fires N+1 queries; eager loading always fires exactly 2

Lazy vs Eager Loading
// 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.

app/Models/Country.php
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.

Polymorphic Migration & Models
// 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 Keys
// 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

Querying Through Relationships
// 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 resultsCollection
first()Return the first resultModel or null
count()Count without loadingint
exists()Check if any records existbool
create([])Create and associateModel
save($model)Save and associate existing modelbool
attach($id)Many-to-many: add to pivotvoid
detach($id)Many-to-many: remove from pivotint
sync([])Many-to-many: replace allarray
toggle([])Many-to-many: flip membershiparray
associate($model)Set belongsTo foreign keyModel
dissociate()Unset belongsTo foreign keyModel

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_tag not tag_post. Stick to convention and you'll rarely need to pass a custom pivot name.
  • Use withCount() for dashboards: User::withCount('posts') adds a posts_count attribute 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.
Further Reading

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.

Laravel Eloquent Relationships ORM PHP Database
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.