Relasi many-to-many antara User dan Role berjalan lewat tabel perantara role_user yang kita perkenalkan di chapter Many to Many . Tabel perantara ini — disebut pivot table — sering kali butuh lebih dari dua foreign key: siapa yang memberikan role, kapan pemberian itu terjadi, hingga status aktifnya. Eloquent menolak memperlakukan pivot sebagai warga kelas dua; kolom ekstra bisa dibaca lewat withPivot(), timestamp bisa dikelola otomatis, bahkan pivot bisa diangkat menjadi model penuh. Kita urai semuanya di chapter ini memakai pasangan user-role dengan kolom tambahan added_by.

Konvensi Nama Tabel Pivot #

Nama pivot table ditebak dari nama kedua model dalam bentuk singular snake_case, diurutkan alfabetis: Role + User menjadi role_user (r sebelum u). Method relasi bisa membuktikannya:

php
use App\Models\User;

$user = User::find(1);

echo 'tabel pivot: ', $user->roles()->getTable(), "\n";
echo "SQL: ", $user->roles()->toSql(), "\n";

Output yang Dihasilkan:

text
tabel pivot: role_user
SQL: select * from "roles" inner join "role_user" on "roles"."id" = "role_user"."role_id" where "role_user"."user_id" = ?

SQL di atas memperlihatkan struktur pengaksesannya: belongsToMany selalu menjalankan inner join ke pivot table lalu menyaring baris milik user yang memanggil. Migration pembuatnya sendiri berbentuk:

php
Schema::create('role_user', function (Blueprint $table) {
    $table->foreignId('role_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->unsignedBigInteger('added_by')->nullable();
    $table->timestamps();
    $table->primary(['role_id', 'user_id']);
});

Dua foreign key membentuk primary key gabungan — satu user tidak mungkin punya role yang sama dua kali. Kolom added_by menyimpan id user yang memberikan role, dan timestamps() mencatat kapan relasi dibuat serta terakhir diubah.

Membaca Kolom Ekstra dengan withPivot() #

Secara bawaan, Eloquent hanya mengambil dua foreign key dari pivot. Kolom lain harus didaftarkan lebih dulu pada definisi relasi:

php
// app/Models/User.php
public function roles()
{
    return $this->belongsToMany(Role::class)
        ->withPivot('added_by')
        ->withTimestamps()
        ->using(RoleUser::class);
}

Setelah terdaftar, kolom tersebut bisa dibaca lewat property pivot pada setiap model Role:

php
foreach ($user->roles as $role) {
    echo $user->name, ' adalah ', $role->name,
        ' (ditambahkan oleh user #', $role->pivot->added_by, ")\n";
}

Output yang Dihasilkan:

text
Budi adalah penulis (ditambahkan oleh user #2)

Pivot Class Kustom dengan ->using() #

Property pivot sejatinya adalah instance model. Baris ->using(RoleUser::class) pada definisi relasi mengganti model pivot bawaan dengan class kita sendiri:

php
// app/Models/RoleUser.php
use Illuminate\Database\Eloquent\Relations\Pivot;

class RoleUser extends Pivot
{
}
php
$pivot = $user->roles->first()->pivot;

echo 'class pivot: ', get_class($pivot), "\n";
echo 'added_by: ', $pivot->added_by, "\n";
echo 'created_at: ', $pivot->created_at->format('Y-m-d H:i'), "\n";

Output yang Dihasilkan:

text
class pivot: App\Models\RoleUser
added_by: 2
created_at: 2026-01-01 08:20

get_class() membuktikan pivot sekarang berupa App\Models\RoleUser, dan created_at sudah berupa objek Carbon — timestamp diurus withTimestamps() yang otomatis mengisi kolom waktu setiap kali baris pivot dibuat atau diubah. Manfaat sebenarnya muncul ketika class ini diisi: cast khusus, helper method, sampai relasi sendiri (misalnya ditambahkanOleh() yang mengembalikan user dari kolom added_by) bisa didefinisikan di sana dan otomatis tersedia di semua relasi yang memakainya.

attach() dan detach() dengan Data Pivot #

Operasi tulis pivot membawa data ekstra sebagai array kedua:

php
$editor = Role::where('name', 'editor')->first();

$user->roles()->attach($editor, ['added_by' => 3]);

foreach ($user->fresh()->roles()->orderBy('roles.id')->get() as $role) {
    echo $role->name, ' — added_by ', $role->pivot->added_by, "\n";
}

Output yang Dihasilkan:

text
editor — added_by 3
penulis — added_by 2

attach() menyisipkan baris pivot baru lengkap dengan added_by 3, sementara baris lama tidak tersentuh. detach() memindahkan baris pivot tanpa menghapus role itu sendiri — $user->roles()->detach([3]) nanti cukup memutus role admin milik Budi, dan tabel roles tetap utuh.

sync(): Penyelarasan Massal yang Idempoten #

Ketika form mengirim daftar role lengkap, sync() menyelaraskan pivot dengan daftar itu: id yang belum ada disisipkan, id yang hilang dilepas, sisanya diperbarui bila data pivotnya berubah:

php
$changes = $user->roles()->sync([
    3 => ['added_by' => 2],
    2 => ['added_by' => 3],
]);

echo json_encode($changes, JSON_PRETTY_PRINT), "\n";
foreach ($user->fresh()->roles()->orderBy('roles.id')->get() as $role) {
    echo $role->name, ' — added_by ', $role->pivot->added_by, "\n";
}

Output yang Dihasilkan:

text
{
    "attached": [
        3
    ],
    "detached": {
        "1": 1
    },
    "updated": [
        2
    ]
}
penulis — added_by 3
admin — added_by 2

Nilai baliknya adalah laporan lengkap: role 3 (admin) baru disisipkan, role 1 (editor) dilepas dari satu baris pivot, dan role 2 (penulis) diperbarui karena added_by berubah. Versi syncWithoutDetaching() memakai logika yang sama tetapi membiarkan baris di luar daftar tetap hidup — perbedaan penting saat satu form hanya mengelola sebagian role user:

php
$changes = $user->roles()->syncWithoutDetaching([1 => ['added_by' => 1]]);

echo json_encode($changes, JSON_PRETTY_PRINT), "\n";

Output yang Dihasilkan:

text
{
    "attached": [
        1
    ],
    "detached": [],
    "updated": []
}

Role editor kembali disisipkan tanpa menghapus penulis dan admin yang sudah ada.

paginate() pada Relasi #

Method relasi adalah query builder, jadi paginator juga tersedia untuk relasi:

php
$halaman = Post::find(4)->comments()->orderBy('id')->paginate(2);

printf("halaman %d dari %d, total %d komentar, per halaman %d\n",
    $halaman->currentPage(), $halaman->lastPage(), $halaman->total(), $halaman->perPage());
foreach ($halaman as $comment) {
    echo 'komentar #', $comment->id, ': ', $comment->isi, "\n";
}

Output yang Dihasilkan:

text
halaman 1 dari 2, total 3 komentar, per halaman 2
komentar #6: Bagian pivot table saya tunggu.
komentar #7: Sudah coba di sandbox, mantap.

Tiga komentar post 4 terbagi dua halaman; parameter bernama page: 2 pada pemanggilan berikutnya mengambil potongan keduanya. Paginator yang dikembalikan adalah LengthAwarePaginator yang sama seperti pada query biasa, lengkap dengan total() dan lastPage().

Pivot table kini terbaca dan terkelola penuh — dari konvensi penamaannya, kolom ekstranya, sampai class modelnya sendiri. Satu pertanyaan tersisa: bagaimana alur kerja menulis lewat relasi dari sisi yang lain — membuat komentar lewat post, menautkan user lewat associate() — dan itulah fokus chapter Relationship Operations berikutnya.