Introduction
Many-to-many relationships connect records where both sides can have multiple links — Posts and Tags, Users and Groups, Products and Categories. They require an intermediate pivot table.
Key Concepts
- Pivot table: The intermediate table (e.g.,
post_tag) that holds the foreign keys linking both sides. belongsToMany: The relationship defined on both sides of the many-to-many.attach/detach/sync/toggle: Methods that modify pivot rows —attachadds,detachremoves,syncreplaces the whole set,toggleflips.- Pivot data (
withPivot): Extra columns on the pivot table accessible via$tag->pivot->column. - Custom Pivot class (
using): A dedicated class extendingPivotfor complex pivot logic.
Real World Context
Tags on posts, users in groups, products in categories, followers and following — many of the most common application features are many-to-many. Knowing when to use sync vs attach, and how to access pivot data, is essential.
Deep Dive
Many-to-many relationships connect records where each side can have multiple related records. For example, Posts can have many Tags, and Tags can belong to many Posts.
The Pivot Table
Many-to-many requires an intermediate (pivot) table:
posts post_tag (pivot) tags
┌────┬──────────┐ ┌─────────┬────────┐ ┌────┬─────────┐
│ id │ title │ │ post_id │ tag_id │ │ id │ name │
├────┼──────────┤ ├─────────┼────────┤ ├────┼───────────┤
│ 1 │ "Post A" │◄──│ 1 │ 1 │──────►│ 1 │ "PHP" │
│ │ │◄──│ 1 │ 2 │──┐ │ 2 │ "Laravel" │
│ 2 │ "Post B" │◄──│ 2 │ 2 │──┴───►│ │ │
└────┴──────────┘ └─────────┴────────┘ └────┴───────────┘
Post A has tags: PHP, Laravel
Post B has tags: Laravel
PHP tag is on: Post A
Laravel tag is on: Post A, Post B
Creating the Pivot Table
bashphp artisan make:migration create_post_tag_table
php// Convention: singular table names in alphabetical order Schema::create('post_tag', function (Blueprint $table) { $table->foreignId('post_id')->constrained()->cascadeOnDelete(); $table->foreignId('tag_id')->constrained()->cascadeOnDelete(); $table->primary(['post_id', 'tag_id']); $table->timestamps(); // Optional but useful });
Defining the Relationship
On Post Model
php<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Post extends Model { /** * Get the tags for the post. */ public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class); } }
On Tag Model
phpclass Tag extends Model { /** * Get the posts with this tag. */ public function posts(): BelongsToMany { return $this->belongsToMany(Post::class); } }
Accessing Related Records
php// Get all tags for a post $post = Post::find(1); foreach ($post->tags as $tag) { echo $tag->name; // "PHP", "Laravel" } // Get all posts with a tag $tag = Tag::where('name', 'Laravel')->first(); foreach ($tag->posts as $post) { echo $post->title; }
Attaching and Detaching
php$post = Post::find(1); // Attach single tag $post->tags()->attach($tagId); // Attach multiple tags $post->tags()->attach([1, 2, 3]); // Attach with pivot data $post->tags()->attach($tagId, ['added_by' => auth()->id()]); // Attach multiple with pivot data $post->tags()->attach([ 1 => ['added_by' => auth()->id()], 2 => ['added_by' => auth()->id()], ]); // Detach $post->tags()->detach($tagId); // Single $post->tags()->detach([1, 2, 3]); // Multiple $post->tags()->detach(); // All tags
Syncing
Replace all existing relationships:
php// Replace all tags with these $post->tags()->sync([1, 2, 3]); // With pivot data $post->tags()->sync([ 1 => ['added_by' => auth()->id()], 2, // No pivot data 3 => ['added_by' => auth()->id()], ]); // Sync without detaching existing $post->tags()->syncWithoutDetaching([1, 2, 3]);
Toggle
Attach if not attached, detach if attached:
php$post->tags()->toggle([1, 2, 3]);
Pivot Table Data
Access pivot table columns:
php// First, tell Eloquent about extra columns public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class) ->withPivot('added_by', 'approved_at') ->withTimestamps(); // Include created_at, updated_at } // Access pivot data $post = Post::find(1); foreach ($post->tags as $tag) { echo $tag->pivot->created_at; echo $tag->pivot->added_by; }
Customizing the Pivot Table
phppublic function tags(): BelongsToMany { return $this->belongsToMany(Tag::class) ->as('tagging') // Rename pivot accessor ->withPivot('added_by') ->withTimestamps(); } // Access foreach ($post->tags as $tag) { echo $tag->tagging->created_at; // Instead of $tag->pivot }
Filtering by Pivot Values
php// Only approved tags $approvedTags = $post->tags()->wherePivot('approved', true)->get(); // Tags added this month $recentTags = $post->tags() ->wherePivot('created_at', '>=', now()->subMonth()) ->get(); // Using wherePivotIn $tags = $post->tags()->wherePivotIn('added_by', [1, 2, 3])->get();
Ordering by Pivot
phppublic function tags(): BelongsToMany { return $this->belongsToMany(Tag::class) ->withTimestamps() ->orderByPivot('created_at', 'desc'); }
Pivot Models (Custom Pivot Classes)
phpuse Illuminate\Database\Eloquent\Relations\Pivot; class PostTag extends Pivot { protected $casts = [ 'approved_at' => 'datetime', ]; public function approvedBy(): BelongsTo { return $this->belongsTo(User::class, 'approved_by'); } } // In Post model public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class) ->using(PostTag::class) ->withPivot('approved_by', 'approved_at'); }
Common Pitfalls
- Naming the pivot table wrong — Must be singular, alphabetical:
post_tag, notposts_tagsortag_post. - Using
attachwhen you wantsync—attachadds to existing relationships;syncreplaces the whole set. Subtle and destructive if wrong. - Forgetting
withPivotfor extra columns — Without it, pivot data is invisible on the model.
Best Practices
- Follow Laravel's naming convention — Singular models, alphabetical order, joined by underscore.
- Reach for
sync()on 'replace all' flows — Saving a post's tags after an edit is a sync, not an attach. - Use Pivot model classes for complex pivot logic — When pivot data has its own behavior or relationships.
Summary
- Pivot table =
singular_singularalphabetical, with{model}_idcolumns for both sides. belongsToManyon both models exposes the relationship.attachadds,detachremoves,syncreplaces,toggleflips.withPivot('column')makes extra pivot columns accessible via->pivot->column.using(CustomPivot::class)enables a full Pivot model class for complex cases.