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 — attach adds, detach removes, sync replaces the whole set, toggle flips.
  • Pivot data (withPivot): Extra columns on the pivot table accessible via $tag->pivot->column.
  • Custom Pivot class (using): A dedicated class extending Pivot for 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

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

php
class Tag extends Model
{
    /**
     * Get the posts with this tag.
     */
    public function posts(): BelongsToMany
    {
        return $this->belongsToMany(Post::class);
    }
}
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

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

php
public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class)
        ->withTimestamps()
        ->orderByPivot('created_at', 'desc');
}

Pivot Models (Custom Pivot Classes)

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

  1. Naming the pivot table wrong — Must be singular, alphabetical: post_tag, not posts_tags or tag_post.
  2. Using attach when you want sync — attach adds to existing relationships; sync replaces the whole set. Subtle and destructive if wrong.
  3. Forgetting withPivot for extra columns — Without it, pivot data is invisible on the model.

Best Practices

  1. Follow Laravel's naming convention — Singular models, alphabetical order, joined by underscore.
  2. Reach for sync() on 'replace all' flows — Saving a post's tags after an edit is a sync, not an attach.
  3. Use Pivot model classes for complex pivot logic — When pivot data has its own behavior or relationships.

Summary

  • Pivot table = singular_singular alphabetical, with {model}_id columns for both sides.
  • belongsToMany on both models exposes the relationship.
  • attach adds, detach removes, sync replaces, toggle flips.
  • withPivot('column') makes extra pivot columns accessible via ->pivot->column.
  • using(CustomPivot::class) enables a full Pivot model class for complex cases.
✓ Completed