Relasi satu-ke-banyak menuntun satu arah: post memiliki comment, comment milik post. Tapi banyak hubungan di aplikasi tidak punya pemilik tunggal — satu user bisa memegang role Admin sekaligus Editor, dan role Admin pasti dipegang banyak user. Foreign key biasa tidak bisa menampung ini karena foreign key hanya boleh menyimpan satu nilai per baris. Solusinya sudah dikenal di teori database sejak lama: tabel perantara, yang di Eloquent disebut pivot table, dan setiap pasangan user-role menjadi satu baris di tabel itu. Chapter ini membangun relasi User dan Role lengkap dengan operasi khas pivot: attach(), detach(), dan sync().

Tabel Perantara dan Konvensinya #

Kita hanya butuh dua migration baru — roles dan tabel perantaranya — karena users sudah ada. Penamaan tabel perantara mengikuti konvensi alfabetis — nama dua model yang berelasi diurutkan abjad lalu digabung:

php
Schema::create('roles', function (Blueprint $table) {
    $table->id();
    $table->string('nama')->unique();
    $table->timestamps();
});

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

role_user dipilih, bukan user_role, karena r mendahului u secara alfabetis. Primary key-nya adalah gabungan dua kolom sehingga satu pasangan user-role hanya bisa muncul sekali — tidak ada user yang mendapat role yang sama dua kali. Kolom created_at dan updated_at sengaja disertakan karena usia sebuah penugasan role sering menjadi informasi yang berharga.

belongsToMany di Kedua Sisi #

Relasi didefinisikan dengan belongsToMany() di kedua model, masing-masing merujuk tabel yang sama dari arah berbeda:

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

// app/Models/Role.php
public function users(): BelongsToMany
{
    return $this->belongsToMany(User::class)->withTimestamps();
}

Tanpa satu pun foreign key disebut manual, belongsToMany(Role::class) menebak semuanya: tabel role_user dari nama kedua class, lalu kolom user_id dan role_id dari nama masing-masing model. Method withTimestamps() meminta Eloquent mengisi created_at dan updated_at setiap kali baris pivot baru dibuat. Mari kita ambil role milik Budi:

php
use Illuminate\Support\Facades\DB;

$budi = User::where('email', '[email protected]')->first();

DB::enableQueryLog();
foreach ($budi->roles as $role) {
    echo $role->nama, ' (diberikan ', $role->pivot->created_at, ")\n";
}
foreach (DB::getQueryLog() as $q) {
    echo 'query: [', $q['query'], "]\n";
}

Output yang Dihasilkan:

text
Admin (diberikan 2026-01-10 08:00:00)
Editor (diberikan 2026-03-05 09:30:00)
query: [select "roles".*, "role_user"."user_id" as "pivot_user_id", "role_user"."role_id" as "pivot_role_id", "role_user"."created_at" as "pivot_created_at", "role_user"."updated_at" as "pivot_updated_at" from "roles" inner join "role_user" on "roles"."id" = "role_user"."role_id" where "role_user"."user_id" = ?]

Satu query dengan INNER JOIN menggabungkan tiga tabel, dan hasilnya kembali sebagai collection Role. Perhatikan kolom pivot_* pada query itu: setiap model Role dalam collection membawa attribute pivot yang berisi baris pivot penghubungnya — lewat attribute inilah $role->pivot->created_at terbaca tanpa query tambahan. Kolom waktu muncul di sana justru karena withTimestamps() yang kita rantai di definisi relasi.

Menyaring Kolom Pivot dengan wherePivot #

Kolom pivot juga bisa menjadi syarat query lewat wherePivot(). Sebagai contoh, mari daftar role Budi yang diberikan sejak Februari:

php
foreach ($budi->roles()->wherePivot('created_at', '>=', '2026-02-01')->get() as $role) {
    echo $role->nama, "\n";
}

Output yang Dihasilkan:

text
Editor

Syarat created_at >= 2026-02-01 diterapkan pada tabel role_user, bukan pada tabel roles — hasilnya hanya Editor yang diberikan pada Maret. Pencarian seperti “role baru tahun ini” atau “member yang bergabung bulan lalu” menjadi satu baris method chained, dan kondisi lain seperti wherePivotIn() serta wherePivotNull() mengikuti pola yang sama sesuai dokumentasi Eloquent Relationships .

Menautkan, Melepas, dan Menyelaraskan #

Tiga method mengelola isi pivot table. Yang pertama, attach() — menambah pasangan baru dari sisi relasi:

php
$anto = User::where('email', '[email protected]')->first();

$anto->roles()->attach(1);
echo 'roles Anto: ', $anto->fresh()->roles->pluck('nama')->implode(', '), "\n";
echo 'pivot created_at terisi: ', var_export($anto->roles()->find(1)->pivot->created_at !== null, true), "\n";

Output yang Dihasilkan:

text
roles Anto: Admin
pivot created_at terisi: true

Angka 1 adalah id role Admin; attach() juga menerima instance model atau array beberapa id sekaligus. Kolom waktu pada baris pivot baru itu terisi otomatis oleh withTimestamps() — dan demi membuktikan perannya, mari definisikan relasi pembanding tanpa method tersebut:

php
// app/Models/User.php — hanya untuk perbandingan
public function rolesLama(): BelongsToMany
{
    return $this->belongsToMany(Role::class);
}
php
$anto->rolesLama()->attach(2);
echo 'roles Anto: ', $anto->fresh()->roles->pluck('nama')->implode(', '), "\n";
echo 'pivot created_at editor: ';
var_export($anto->rolesLama()->find(2)->pivot->created_at);
echo "\n";

Output yang Dihasilkan:

text
roles Anto: Admin, Editor
pivot created_at editor: NULL

Editor tertaut, tetapi kolom waktunya NULL — attach() memang hanya menulis dua foreign key, dan tanpa withTimestamps() tidak ada yang mengisi sisanya. Selanjutnya, detach() melepas pasangan — beri id untuk melepas satu, atau kosongkan untuk melepas semuanya:

php
$anto->roles()->detach();
echo 'roles Anto setelah detach: ', $anto->fresh()->roles->pluck('nama')->implode(', ') ?: '(kosong)', "\n";

Output yang Dihasilkan:

text
roles Anto setelah detach: (kosong)

Terakhir, sync() menyelaraskan seluruh isi pivot dengan daftar yang kita berikan — menambah yang belum ada, melepas yang tidak disebut:

php
$hasil1 = $budi->roles()->sync([2]);
echo 'hasil sync([2]): ';
var_export($hasil1);
echo "\n";
echo 'roles Budi sekarang: ', $budi->fresh()->roles->pluck('nama')->implode(', '), "\n";

$hasil2 = $budi->roles()->sync([1, 3]);
echo 'hasil sync([1, 3]): ';
var_export($hasil2);
echo "\n";
echo 'roles Budi akhir: ', $budi->fresh()->roles->pluck('nama')->implode(', '), "\n";

Output yang Dihasilkan:

text
hasil sync([2]): array (
  'attached' =>
  array (
  ),
  'detached' =>
  array (
    0 => 1,
  ),
  'updated' =>
  array (
  ),
)
roles Budi sekarang: Editor
hasil sync([1, 3]): array (
  'attached' =>
  array (
    0 => 1,
    1 => 3,
  ),
  'detached' =>
  array (
    0 => 2,
  ),
  'updated' =>
  array (
  ),
)
roles Budi akhir: Admin, Penulis

Nilai balik sync() adalah laporan lengkap: role 1 dilepas pada panggilan pertama, lalu 1 dan 3 ditambahkan sementara 2 dilepas pada panggilan kedua. Perilaku “jadikan persis seperti daftar ini” membuat sync() menjadi pilihan aman untuk form penyuntingan role, jauh lebih bersih daripada detach() semua lalu attach() ulang.

Relasi user-role kini terhubung dua arah lewat satu tabel perantara. Namun ada satu pola yang belum terjawab: bagaimana bila satu tabel yang sama — katakanlah comments — harus berelasi ke post sekaligus video? Di titik itu foreign key tunggal tidak lagi memadai, dan Polymorphic Relationships menghadirkan solusinya.