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:
use App\Models\User;
$user = User::find(1);
echo 'tabel pivot: ', $user->roles()->getTable(), "\n";
echo "SQL: ", $user->roles()->toSql(), "\n";Output yang Dihasilkan:
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:
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:
// 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:
foreach ($user->roles as $role) {
echo $user->name, ' adalah ', $role->name,
' (ditambahkan oleh user #', $role->pivot->added_by, ")\n";
}Output yang Dihasilkan:
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:
// app/Models/RoleUser.php
use Illuminate\Database\Eloquent\Relations\Pivot;
class RoleUser extends Pivot
{
}$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:
class pivot: App\Models\RoleUser
added_by: 2
created_at: 2026-01-01 08:20get_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:
$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:
editor — added_by 3
penulis — added_by 2attach() 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:
$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:
{
"attached": [
3
],
"detached": {
"1": 1
},
"updated": [
2
]
}
penulis — added_by 3
admin — added_by 2Nilai 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:
$changes = $user->roles()->syncWithoutDetaching([1 => ['added_by' => 1]]);
echo json_encode($changes, JSON_PRETTY_PRINT), "\n";Output yang Dihasilkan:
{
"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:
$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:
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.