Relasi many-to-many menghubungkan dua tabel yang sama-sama bisa punya banyak pasangan di seberangnya. Di app Perpustakaan, satu orang bisa memegang beberapa peran (role), misalnya admin dan editor katalog, dan satu peran dipegang beberapa orang. Bayangin buku daftar tugas di meja petugas: tiap baris cuma berisi satu pasangan, "Budi pegang admin", "Budi pegang editor", "Ani pegang pustakawan". Mau tahu peran Budi, cari baris bernama Budi. Mau tahu siapa saja pustakawan, cari baris pustakawan. Buku daftar itu di database disebut tabel pivot.
🧪 Tebakan cepat: Budi memegang role 1 (admin) dan 2 (editor). Lalu
$budi->roles()->sync([1, 3]);dijalankan. Sesudahnya Budi memegang role apa saja, dan baris mana di tabel pivot yang dihapus? Tebak dulu, jawabannya ada di bagian "attach, detach, sync".
Kenapa butuh tabel ketiga
Di pelajaran Relasi Eloquent, satu peminjaman cukup menyimpan user_id. Di sini itu gak cukup: kolom role_id di users cuma muat satu role, dan kolom user_id di roles cuma muat satu orang. Jadi pasangannya disimpan di tabel ketiga:
| users | roles | role_user (pivot) |
|---|---|---|
| 1 Budi | 1 admin | user 1, role 1 |
| 2 Ani | 2 editor | user 1, role 2 |
| 3 Citra | 3 pustakawan | user 2, role 3 |
Budi admin dan editor, Ani pustakawan, Citra anggota biasa tanpa role. Migration tabel pivot-nya:
Schema::create('role_user', function (Blueprint $table) {
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->primary(['user_id', 'role_id']);
});
- Nama tabelnya mengikuti aturan Laravel: dua nama model dalam bentuk tunggal, diurutkan sesuai abjad, disambung garis bawah.
rolelebih dulu dariuser, jadirole_user. foreignId(...)->constrained()memasang foreign key keusers.iddanroles.id.cascadeOnDelete()ikut menghapus baris pivot saat user atau role-nya dihapus.primary(['user_id', 'role_id'])membuat pasangan yang sama gak bisa tercatat dua kali.
belongsToMany di kedua model
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class User extends Model
{
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class);
}
}
class Role extends Model
{
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
}
- Kedua sisi memakai method yang sama,
belongsToMany. Gak ada sisi yang memegang foreign key, karena kuncinya ada di pivot. - Laravel menebak tabel
role_userdengan kolomuser_iddanrole_id. Pivot-mu bernama lain? Sebut di argumen kedua:$this->belongsToMany(Role::class, 'user_roles'). - Membaca, tanpa kurung:
$budi->rolesmengirim satu query dengan JOIN ke pivot:
select "roles".*, "role_user"."user_id" as "pivot_user_id", "role_user"."role_id" as "pivot_role_id" from "roles" inner join "role_user" on "roles"."id" = "role_user"."role_id" where "role_user"."user_id" = 1
Hasilnya Collection berisi admin dan editor. Dari sisi seberang, Role::find(3)->users berisi Ani. $budi->roles->contains(1) menjawab true.
attach, detach, sync
Mengubah pasangan dilakukan lewat relasi dengan kurung, $budi->roles(). Tiap baris tabel ini mulai dari keadaan yang sama, Budi memegang role 1 dan 2. SQL-nya persis yang dikirim Laravel 13 ke SQLite:
| Kode | SQL yang dikirim | Role Budi sesudahnya |
|---|---|---|
attach(3) |
insert into "role_user" ("role_id", "user_id") values (3, 1) |
1, 2, 3 |
detach(2) |
delete from "role_user" where "role_user"."user_id" = 1 and "role_user"."role_id" in (2) |
1 |
sync([1, 3]) |
select * from "role_user" where "role_user"."user_id" = 1, lalu delete ... in (2), lalu insert ... values (3, 1) |
1, 3 |
detach() |
delete from "role_user" where "role_user"."user_id" = 1 |
kosong |
attachmenambah baris pivot. Banyak sekaligus:attach([2, 3]).detachmenghapus baris pivot dan mengembalikan jumlah baris yang terhapus. Tanpa argumen, semua role Budi dilepas.syncmembuat isi pivot persis sama dengan daftarnya. Laravel membaca pasangan yang ada, menghapus yang gak ada di daftar (editor), menambah yang belum ada (pustakawan), dan membiarkan yang sudah cocok (admin). Budi jadi admin dan pustakawan, dan baris "user 1, role 2" yang dihapus. Itu jawaban tebakannya. Nilai kembaliannya['attached' => [3], 'detached' => [2], 'updated' => []].
📌 Kolom tambahan di pivot
Pivot boleh punya kolom sendiri, misalnya kapan role-nya diberikan. Tambahkan $table->timestamps() di migration, lalu ->withTimestamps() di relasinya: return $this->belongsToMany(Role::class)->withTimestamps();. Kolom lain dibaca dengan ->withPivot('assigned_by'), diisi dengan attach(3, ['assigned_by' => 2]), dan dibaca lewat $role->pivot->assigned_by. Kalau satu tabel perlu dipasangkan ke banyak jenis model (tag untuk buku dan untuk artikel), lihat pelajaran Polymorphic Relations. Cara memakai role untuk izin dibahas di pelajaran Role-based Access Control.
Di latihan
Kode awal sudah punya class User dengan method roles() dan class Role dengan method users(), dua-duanya kosong. Isi roles() dengan return $this->belongsToMany(Role::class); dan users() dengan return $this->belongsToMany(User::class);. Simulator memuat kedua class lalu mencetak Class defined: User dan Class defined: Role. Relasinya dinilai dari kodenya: harus ada belongsToMany(.
⚠️ Jebakan yang sering ketemu
- Nama pivot terbalik. Migration membuat
user_role, padahal Laravel mencarirole_user:no such table: role_user. Ikuti urutan abjad, atau sebut namanya di argumen kedua. - attach pasangan yang sudah ada.
attach(2)saat Budi sudah editor ditolak database:UNIQUE constraint failed: role_user.user_id, role_user.role_id. PakaisyncWithoutDetaching([2]), yang cuma menambah yang belum ada. - Lupa kurung.
$budi->roles->attach(3)memanggilattachpada Collection hasil baca:Method Illuminate\Database\Eloquent\Collection::attach does not exist.Tulis$budi->roles()->attach(3). - sync dengan daftar kosong.
sync([])melepas semua role, persis sepertidetach().
TL;DR: Many-to-many butuh tabel pivot, namanya dua model tunggal urut abjad: role_user, berisi user_id dan role_id dengan primary key gabungan. Di kedua model tulis return $this->belongsToMany(...): roles() di User, users() di Role. Baca tanpa kurung ($budi->roles, satu query dengan JOIN ke pivot), ubah dengan kurung: attach(3) menambah baris, detach(2) menghapus, detach() menghapus semua, dan sync([1, 3]) membuat isinya persis daftar itu (menghapus yang gak ada, menambah yang baru) lalu mengembalikan attached, detached, updated. attach pasangan yang sudah ada melanggar UNIQUE. Kolom tambahan di pivot memakai withTimestamps() dan withPivot(...).