BAB 20: Eloquent Relationships

Mendefinisikan dan menggunakan relasi antar model di Eloquent untuk merepresentasikan hubungan data.

Di akhir bab sebelumnya kita menyebut bahwa model Catatan punya user_id yang merujuk ke tabel users, tapi kita masih harus join manual untuk mengaksesnya. Itulah persis batas yang akan kita lampaui sekarang.

Eloquent Relationships memungkinkan kamu mendefinisikan hubungan antar model sekali, lalu mengaksesnya seperti properti biasa. $catatan->penulis — tanpa join, tanpa query manual, tanpa stdClass berantakan. Eloquent akan mengurus SQL di balik layar.

Jenis Relasi yang Perlu Kamu Kuasai

Sebelum masuk ke kode, penting untuk memahami tiga jenis relasi yang paling sering digunakan:

  • One-to-One — satu baris di tabel A berhubungan dengan tepat satu baris di tabel B. Contoh: satu user punya satu profil.
  • One-to-Many — satu baris di tabel A berhubungan dengan banyak baris di tabel B. Contoh: satu user punya banyak catatan.
  • Many-to-Many — banyak baris di tabel A berhubungan dengan banyak baris di tabel B melalui tabel pivot. Contoh: satu catatan bisa punya banyak label, satu label bisa menempel ke banyak catatan.

Aplikasi manajemen catatan yang kita bangun sepanjang ebook ini mengandung ketiga jenis relasi ini. Mari kita definisikan satu per satu.

One-to-One: User dan Profil

Setiap user di aplikasi kita akan punya satu profil yang menyimpan informasi tambahan seperti bio dan foto. Ini adalah relasi one-to-one: satu User memiliki satu Profil, dan satu Profil dimiliki oleh satu User.

Pertama, buat model dan migration:

php artisan make:model Profil -m

Buka file migration yang baru dibuat dan tambahkan kolom yang dibutuhkan:

// database/migrations/xxxx_create_profils_table.php
public function up(): void
{
    Schema::create('profils', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained()->cascadeOnDelete();
        $table->string('bio')->nullable();
        $table->string('foto')->nullable();
        $table->timestamps();
    });
}

Sekarang definisikan relasi di model User:

// app/Models/User.php
use Illuminate\Database\Eloquent\Relations\HasOne;

class User extends Authenticatable
{
    public function profil(): HasOne
    {
        return $this->hasOne(Profil::class);
    }
}

Dan di model Profil, definisikan arah sebaliknya menggunakan belongsTo:

// app/Models/Profil.php
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Profil extends Model
{
    protected $fillable = ['user_id', 'bio', 'foto'];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

Dengan dua method ini terdefinisi, mengakses profil user menjadi semudah mengakses properti:

$user = User::find(1);

// Akses profil dari user
echo $user->profil->bio;

// Akses user dari profil
$profil = Profil::find(1);
echo $profil->user->name;

Eloquent mendeteksi foreign key secara otomatis berdasarkan nama model: hasOne(Profil::class) mengasumsikan tabel profils punya kolom user_id. Jika nama kolommu berbeda, teruskan sebagai argumen kedua: $this->hasOne(Profil::class, 'pemilik_id').

One-to-Many: User dan Catatan

Relasi yang paling sering muncul dalam aplikasi. Model User yang sudah kita punya bisa dihubungkan ke model Catatan yang juga sudah ada:

// app/Models/User.php
use Illuminate\Database\Eloquent\Relations\HasMany;

class User extends Authenticatable
{
    public function profil(): HasOne
    {
        return $this->hasOne(Profil::class);
    }

    public function catatans(): HasMany
    {
        return $this->hasMany(Catatan::class);
    }
}

Model Catatan sudah punya kolom user_id, jadi kita tinggal tambahkan belongsTo di sisinya:

// app/Models/Catatan.php
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Catatan extends Model
{
    use SoftDeletes;

    protected $fillable = [
        'user_id', 'judul', 'isi', 'prioritas', 'selesai',
    ];

    public function penulis(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}

Catatan: nama method tidak harus sama persis dengan nama model. Kita pakai penulis() bukan user() karena lebih mencerminkan konteks domain aplikasi. Eloquent tetap bisa mendeteksi model yang dimaksud dari argumen User::class.

Mengakses relasi ini:

$user = User::find(1);

// Semua catatan milik user ini (Collection of Catatan)
$semua = $user->catatans;

// Bisa difilter lebih lanjut — ini mengembalikan query builder
$mendesak = $user->catatans()->where('prioritas', 'tinggi')->get();

// Dari sisi catatan
$catatan = Catatan::find(5);
echo $catatan->penulis->name;

Perbedaan antara $user->catatans (properti, langsung dieksekusi) dan $user->catatans() (method, mengembalikan query builder yang bisa dirantai) adalah hal penting yang perlu diingat.

Many-to-Many: Catatan dan Label

Di aplikasi kita, sebuah catatan bisa punya lebih dari satu label (misalnya “kerja”, “pribadi”, “ide”), dan satu label bisa menempel ke banyak catatan. Inilah many-to-many.

Relasi ini butuh tabel pivot sebagai jembatan. Buat model dan tabel yang diperlukan:

php artisan make:model Label -m
php artisan make:migration create_catatan_label_table
// database/migrations/xxxx_create_labels_table.php
public function up(): void
{
    Schema::create('labels', function (Blueprint $table) {
        $table->id();
        $table->string('nama');
        $table->string('warna')->default('#6b7280');
        $table->timestamps();
    });
}
// database/migrations/xxxx_create_catatan_label_table.php
public function up(): void
{
    Schema::create('catatan_label', function (Blueprint $table) {
        $table->foreignId('catatan_id')->constrained()->cascadeOnDelete();
        $table->foreignId('label_id')->constrained()->cascadeOnDelete();
        $table->primary(['catatan_id', 'label_id']);
    });
}

Nama tabel pivot mengikuti konvensi alphabetical — catatan_label, bukan label_catatan. Tabel pivot biasanya tidak punya kolom id dan timestamps kecuali memang dibutuhkan.

Definisikan relasi di kedua model:

// app/Models/Catatan.php
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Catatan extends Model
{
    // ... properti dan relasi sebelumnya

    public function labels(): BelongsToMany
    {
        return $this->belongsToMany(Label::class);
    }
}
// app/Models/Label.php
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Label extends Model
{
    protected $fillable = ['nama', 'warna'];

    public function catatans(): BelongsToMany
    {
        return $this->belongsToMany(Catatan::class);
    }
}

Mengelola Data di Tabel Pivot

Untuk relasi many-to-many, Eloquent menyediakan method khusus untuk mengelola data di tabel pivot:

$catatan = Catatan::find(1);

// Tambahkan label ke catatan (insert ke pivot)
$catatan->labels()->attach(3);
$catatan->labels()->attach([3, 5, 7]);

// Hapus label dari catatan
$catatan->labels()->detach(3);
$catatan->labels()->detach(); // hapus semua

// Sync — buat pivot hanya untuk ID yang ada di array, hapus sisanya
$catatan->labels()->sync([1, 4]);

// Toggle — attach jika belum ada, detach jika sudah ada
$catatan->labels()->toggle([2, 6]);

sync() adalah method yang paling sering dipakai di form edit: tinggal kirim array ID label yang dipilih user, sisanya ditangani Eloquent.

Mengakses label dari catatan:

$catatan = Catatan::find(1);

foreach ($catatan->labels as $label) {
    echo $label->nama;      // "kerja"
    echo $label->warna;     // "#3b82f6"
}

Eager Loading: Mencegah N+1 Query

Ini adalah topik yang paling sering diabaikan tapi paling berdampak pada performa.

Perhatikan kode ini:

$catatans = Catatan::all();

foreach ($catatans as $catatan) {
    echo $catatan->penulis->name;
}

Kelihatannya tidak ada masalah. Tapi jika ada 50 catatan, Eloquent akan menjalankan 51 query: 1 untuk mengambil semua catatan, lalu 1 query per catatan untuk mengambil penulisnya. Ini disebut N+1 problem.

Solusinya adalah eager loading dengan with():

// 2 query total, berapapun jumlah catatannya
$catatans = Catatan::with('penulis')->get();

foreach ($catatans as $catatan) {
    echo $catatan->penulis->name; // tidak ada query tambahan
}

Eloquent menjalankan satu query untuk semua catatan, lalu satu query lagi untuk semua penulis yang relevan — kemudian menggabungkannya di memori. Hasilnya sama persis, tapi jauh lebih efisien.

Eager Loading Beberapa Relasi Sekaligus

// Muat beberapa relasi sekaligus
$catatans = Catatan::with(['penulis', 'labels'])->get();

// Nested — muat relasi dari relasi
$users = User::with('catatans.labels')->get();

// Eager load dengan kondisi
$catatans = Catatan::with([
    'labels' => function ($query) {
        $query->where('warna', '!=', '#6b7280');
    }
])->get();

Load Setelah Query

Jika collection sudah diambil dan kamu baru menyadari perlu relasi tertentu, gunakan load():

$catatans = Catatan::all();

// Baru sadar butuh penulis — load setelah fakta
$catatans->load('penulis');

// loadMissing — hanya load jika belum dimuat
$catatans->loadMissing('labels');

Aktifkan preventLazyLoading di environment development untuk mendapat peringatan otomatis setiap kali lazy loading terjadi. Tambahkan ini di AppServiceProvider::boot():

use Illuminate\Database\Eloquent\Model;

Model::preventLazyLoading(!app()->isProduction());

Ini akan melempar exception saat relasi diakses tanpa eager loading, sehingga kamu bisa segera mendeteksi N+1 sebelum masuk ke production.

withCount: Menghitung Relasi Tanpa Memuatnya

Untuk menampilkan jumlah catatan per user tanpa memuat semua catatan ke memori:

$users = User::withCount('catatans')->get();

foreach ($users as $user) {
    echo $user->catatans_count; // misalnya: 12
}

Eloquent menambahkan kolom {relasi}_count ke hasil query. Bisa dikombinasikan dengan kondisi:

$users = User::withCount([
    'catatans',
    'catatans as catatan_selesai_count' => function ($query) {
        $query->where('selesai', true);
    },
])->get();

echo $user->catatans_count;         // total catatan
echo $user->catatan_selesai_count;  // catatan yang sudah selesai

Querying Relasi dengan has dan whereHas

Untuk memfilter model berdasarkan kondisi di relasinya:

// User yang punya setidaknya satu catatan
$users = User::has('catatans')->get();

// User yang punya lebih dari 5 catatan
$users = User::has('catatans', '>=', 5)->get();

// User yang tidak punya catatan sama sekali
$users = User::doesntHave('catatans')->get();

// User yang punya catatan dengan prioritas tinggi
$users = User::whereHas('catatans', function ($query) {
    $query->where('prioritas', 'tinggi');
})->get();

// Catatan yang memiliki label tertentu
$catatans = Catatan::whereHas('labels', function ($query) {
    $query->where('nama', 'pekerjaan');
})->get();

Singkatan untuk kondisi sederhana:

// Setara dengan whereHas + where satu kolom
$catatans = Catatan::whereRelation('labels', 'nama', 'pekerjaan')->get();

Latihan

Coba kerjakan latihan berikut untuk memastikan pemahamanmu solid:

  1. Menampilkan catatan dengan label — Di controller yang sudah ada, ubah query pengambilan catatan agar juga memuat relasi labels menggunakan eager loading. Tampilkan nama label di view Blade sebagai badge kecil di setiap catatan.

  2. Menyimpan label ke catatan — Buat form tambah/edit catatan yang menyertakan pilihan label (bisa pilih lebih dari satu). Di controller, gunakan sync() untuk menyimpan relasi many-to-many setelah catatan disimpan.

  3. withCount di dashboard — Di halaman dashboard atau daftar user, tampilkan jumlah catatan per user menggunakan withCount. Bandingkan performa dengan cara naif: $user->catatans->count().

Penutup Bab

Relasi Eloquent mengubah cara kamu memodelkan domain aplikasi. $catatan->penulis, $catatan->labels, $user->catatans — kode ini berbicara tentang bisnis, bukan tentang JOIN dan foreign key. Itulah tujuan ORM yang sesungguhnya.

Satu hal yang perlu diingat: relasi yang sudah terdefinisi bagus pun bisa jadi beban performa jika diakses secara ceroboh. Biasakan menggunakan with() setiap kali kamu tahu akan mengakses relasi di dalam loop, dan aktifkan preventLazyLoading agar Laravel sendiri yang mengingatkanmu.

Ada satu hal yang belum kita sentuh: bagaimana cara mengontrol tampilan data sebelum ditampilkan atau sebelum disimpan ke database. Kolom selesai yang bertipe boolean di database kadang perlu ditampilkan sebagai teks “Ya”/“Tidak”. Tanggal created_at perlu diformat sesuai preferensi. Kolom JSON perlu otomatis di-decode menjadi array PHP. Semua itu adalah domain accessor, mutator, dan casting — topik yang akan kita bahas selanjutnya.

Referensi

  1. 1Eloquent: Relationships — Laravel 12.x Documentation
  2. 2Eloquent: Eager Loading — Laravel 12.x Documentation
  3. 3Eloquent: Querying Relations — Laravel 12.x Documentation