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();
}
}