0

Custom pivot models — using(), withPivot()

Advanced5 min read·lv-12-024
sql

Concept

Custom pivot models give you a first-class Eloquent model for the pivot table in a many-to-many relationship, enabling accessors, casts, methods, and events on the pivot data itself.

By default, pivot data is accessed as a basic Illuminate\Database\Eloquent\Relations\Pivot object on $model->pivot. With a custom pivot model, you get the full power of Eloquent.

Setup:

  1. Create a class extending Illuminate\Database\Eloquent\Relations\Pivot.
  2. Reference it in the relationship with ->using(PivotClass::class).
  3. Declare any extra pivot columns with ->withPivot('column1', 'column2').
  4. Optionally add ->withTimestamps().

as(string $name): Rename ->pivot to a more descriptive accessor: ->as('subscription')$user->plan->subscription->expires_at.

Pivot model limitations:

  • Pivot models do NOT have HasFactory by default.
  • incrementing defaults to false unless the pivot table has its own auto-increment PK.
  • To use events on a pivot model, the pivot table needs its own id column and the relationship needs ->withPivot('id').

Querying via pivot model: The pivot model is NOT queryable directly like a regular model (Pivot::where() won't work well). Query through the relationship instead.

Code Example

php
<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Database\Eloquent\Casts\Attribute;

// Custom pivot model
class UserRole extends Pivot
{
    protected $table = 'role_user'; // explicit table name
    public $incrementing = false;   // no auto-increment ID unless added

    protected $casts = [
        'assigned_at' => 'datetime',
        'expires_at'  => 'datetime',
        'permissions' => 'array',
    ];

    // Accessor on pivot data
    public function isExpired(): bool
    {
        return $this->expires_at !== null && $this->expires_at->isPast();
    }

    public function remainingDays(): int
    {
        if ($this->expires_at === null) return PHP_INT_MAX;
        return max(0, now()->diffInDays($this->expires_at, false));
    }
}

// User model
class User extends \Illuminate\Database\Eloquent\Model
{
    public function roles(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
    {
        return $this->belongsToMany(Role::class)
                    ->using(UserRole::class)             // custom pivot class
                    ->as('assignment')                   // rename ->pivot to ->assignment
                    ->withPivot(['assigned_at', 'expires_at', 'permissions'])
                    ->withTimestamps();
    }
}

// Subscription example — pivot with its own table
class PlanSubscription extends Pivot
{
    protected $table = 'plan_user';
    public $incrementing = true;
    protected $casts = [
        'started_at'  => 'datetime',
        'renews_at'   => 'datetime',
        'cancelled_at' => 'datetime',
    ];

    public function isActive(): bool
    {
        return $this->cancelled_at === null && $this->renews_at->isFuture();
    }
}

// Usage
$user = User::with('roles')->find(1);
foreach ($user->roles as $role) {
    echo $role->name;
    echo $role->assignment->assigned_at->toDateString(); // from custom pivot
    if ($role->assignment->isExpired()) {
        $user->roles()->updateExistingPivot($role->id, ['expires_at' => now()->addYear()]);
    }
}

// Attaching with pivot data
$user->roles()->attach(3, [
    'assigned_at' => now(),
    'expires_at'  => now()->addYear(),
    'permissions' => ['read', 'write'],
]);