0

Many-to-many relationships — belongsToMany, pivot tables

Intermediate5 min read·lv-12-007
sqlinterview

Concept

belongsToMany (many-to-many) requires a pivot table — an intermediate table with foreign keys to both related models. Eloquent handles pivot tables transparently.

Pivot table conventions:

  • Named alphabetically by singular model names with underscore: role_user, post_tag.
  • Contains at minimum: user_id, role_id (foreign keys to both tables).
  • Can have additional data columns (timestamps, extra fields).

withPivot(): Access pivot table columns via $model->pivot->column_name. Must declare columns in relationship definition: belongsToMany()->withPivot('expires_at').

withTimestamps(): Track created_at/updated_at on the pivot table.

Attaching/Detaching/Syncing:

  • attach($id or array): Add a relationship.
  • detach($id): Remove a relationship.
  • sync(array): Make relationships match exactly the given IDs (adds missing, removes extra).
  • syncWithoutDetaching(array): Like sync but never removes.
  • toggle(array): Attach if detached, detach if attached.
  • updateExistingPivot($id, array): Update pivot data for an existing relationship.

Custom pivot models: Extend Illuminate\Database\Eloquent\Relations\Pivot for rich pivot logic. Register with using(PivotClass::class) on the relationship.

Code Example

php
<?php
class User extends Model
{
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)       // pivot table: role_user
                    ->withPivot('assigned_at', 'expires_at')
                    ->withTimestamps();
    }
}

class Post extends Model
{
    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class);       // pivot table: post_tag
    }
}

// Attach / Detach
$user = User::find(1);
$user->roles()->attach(3);                     // add role 3
$user->roles()->attach([3, 4, 5]);             // add multiple
$user->roles()->attach(3, ['expires_at' => now()->addYear()]); // with pivot data
$user->roles()->detach(3);                     // remove role 3
$user->roles()->detach([3, 4]);                // remove multiple
$user->roles()->detach();                      // remove ALL roles

// Sync — reconcile to exactly these IDs
$user->roles()->sync([1, 2, 3]);              // user now has exactly roles 1, 2, 3
$user->roles()->syncWithoutDetaching([4]);     // add 4, don't remove existing

// Toggle
$post->tags()->toggle([1, 2, 3]);             // adds if missing, removes if present

// Updating pivot data
$user->roles()->updateExistingPivot(3, ['expires_at' => now()->addYear()]);

// Accessing pivot data
$user = User::with('roles')->find(1);
foreach ($user->roles as $role) {
    echo $role->name;
    echo $role->pivot->expires_at;    // pivot data via ->pivot
    echo $role->pivot->created_at;    // if withTimestamps()
}

// Custom pivot model
class RoleUser extends \Illuminate\Database\Eloquent\Relations\Pivot
{
    protected $casts = ['expires_at' => 'datetime'];

    public function isExpired(): bool { return $this->expires_at?->isPast() ?? false; }
}

class User extends Model
{
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)
                    ->using(RoleUser::class)   // custom pivot model
                    ->withPivot('expires_at')
                    ->withTimestamps();
    }
}