Backend

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

Mayur Dabhi
Mayur Dabhi
April 21, 2026
14 min read

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.

Why Bother With Relationships?

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
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

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

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

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.

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 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

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

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.

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

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.

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 one relationship fires 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

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.

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

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.

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

Legacy databases rarely follow Laravel's naming. When they don't, pass the key names yourself:

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

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()); in AppServiceProvider::boot(). Locally, any accidental N+1 throws a LazyLoadingViolationException instead of silently slowing the page, while production keeps working.
  • Using attach() where sync() 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. On posts.user_id it 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_id can't have a real foreign key constraint. If only one model will ever have comments, a plain hasMany with a proper foreign key is simpler and safer.
  • hasManyThrough for deep chains: It handles exactly one intermediate table. For anything deeper, I'd write the join or a query scope rather than stacking relations.
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

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.

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.