Ilustrasi relasi database dan authorization di Laravel

Data di dunia nyata jarang hidup sendirian. Artikel ditulis oleh user, komentar menempel pada artikel, artikel punya banyak tag. Episode #75 mengajarkan tabel tunggal, episode #77 mengajarkan siapa pengunjungmu. Sekarang dua dunia itu disambungkan: relasi antar tabel di database, dan authorization, aturan tentang siapa boleh melakukan apa.

Posisinya: ini episode yang mengubah aplikasimu dari "bisa simpan data" menjadi "aplikasi beneran". Tanpa relasi, kamu akan menulis query manual yang berantakan. Tanpa authorization, user A bisa menghapus artikel user B. Keduanya wajib sebelum proyek blog mini di episode #79.

Persepsi yang perlu diluruskan dulu

Salah kaprah 1: "Relasi database artinya nulis JOIN manual di setiap query." JOIN memang cara SQL menggabungkan tabel, tapi di Laravel kamu mendefinisikan relasinya sekali di model, lalu memakainya seperti properti biasa: $artikel->user->name atau $user->artikels. Eloquent yang menerjemahkannya jadi query. Kamu berpikir dalam objek ("artikel ini milik siapa"), bukan dalam tabel ("join tabel users on...").

Salah kaprah 2: "Cek user_id di controller sudah cukup untuk keamanan." Menulis if ($artikel->user_id !== auth()->id()) abort(403) di setiap method memang jalan, tapi aturannya tersebar di banyak tempat, mudah lupa di satu method, dan tidak bisa dipakai ulang di Blade. Laravel punya Gate dan Policy: aturan terpusat, bisa dipanggil dari controller, Blade, bahkan API. Keamanan yang tersebar adalah keamanan yang bocor.

Tiga relasi yang wajib dikuasai

Analoginya seperti keluarga. Satu ibu punya banyak anak (one-to-many). Setiap anak punya satu ibu kandung (kebalikannya, belongs-to). Siswa dan ekstrakurikuler: satu siswa ikut banyak ekskul, satu ekskul diikuti banyak siswa (many-to-many). Tiga pola ini mencakup hampir semua kebutuhan aplikasi biasa.

One-to-many: user punya banyak artikel. Di tabel artikels tambah kolom user_id sebagai foreign key. Di model:

// app/Models/User.php
public function artikels()
{
    return $this->hasMany(Artikel::class);
}

// app/Models/Artikel.php
public function user()
{
    return $this->belongsTo(User::class);
}

Aturan mengingatnya: foreign key selalu tinggal di sisi "many". Tabel artikels (yang banyak) menyimpan user_id. belongsTo dipakai model yang menyimpan foreign key-nya, hasMany dipakai model yang tidak menyimpan apa-apa.

Berhenti dan tebak dulu! Relasi komentar ke artikel: satu artikel punya banyak komentar. Di model Komentar pakai hasMany atau belongsTo? Kenapa?

// Tabel komentars punya kolom artikel_id
class Komentar extends Model
{
    public function artikel()
    {
        return $this->??? (Artikel::class);
    }
}
Jawaban: belongsTo. Kenapa: tabel komentars menyimpan foreign key artikel_id, jadi dari sisi Komentar ia "milik" satu artikel. Patokannya selalu: model yang tabelnya menyimpan foreign key pakai belongsTo. Kalau kamu menjawab hasMany, bayangkan: satu komentar punya banyak artikel? Tidak masuk akal. Uji dengan logika bahasa sehari-hari seperti ini setiap kali ragu.

Many-to-many: artikel punya banyak tag. Butuh tabel perantara (pivot), misal artikel_tag berisi artikel_id dan tag_id:

// app/Models/Artikel.php
public function tags()
{
    return $this->belongsToMany(Tag::class);
}

Memakainya semudah ini:

$artikel = Artikel::find(1);
foreach ($artikel->tags as $tag) {
    echo $tag->nama;
}
// Menambah relasi:
$artikel->tags()->attach($tagId);

Eager loading dan jebakan N+1

Ini jebakan performa paling klasik di Eloquent. Perhatikan kode menampilkan 100 artikel beserta nama penulisnya:

$artikels = Artikel::all();
foreach ($artikels as $artikel) {
    echo $artikel->user->name; // query baru setiap putaran!
}

Kode ini menjalankan 1 query untuk mengambil artikel, plus 100 query untuk mengambil user tiap artikel. Total 101 query untuk satu halaman. Ini masalah N+1: satu query awal plus N query susulan. Solusinya eager loading dengan with():

$artikels = Artikel::with('user')->get();
foreach ($artikels as $artikel) {
    echo $artikel->user->name; // tanpa query tambahan
}

Sekarang cuma 2 query: satu untuk artikel, satu untuk semua user yang dibutuhkan. Alur berpikirnya: kalau kamu tahu dari awal akan butuh data relasi, minta semuanya sekaligus di depan. Jangan mengambil induknya dulu baru menjemput anaknya satu per satu seperti kurir yang bolak-balik 100 kali.

Berhenti dan tebak dulu! Kode di bawah menampilkan 50 artikel, tiap artikel menampilkan nama penulis DAN jumlah komentarnya. Berapa query yang jalan tanpa with()? Berapa dengan with(['user', 'komentars'])?

$artikels = Artikel::all();
foreach ($artikels as $a) {
    echo $a->user->name;
    echo count($a->komentars);
}
Jawaban: tanpa with() = 101 query (1 + 50 + 50). Dengan with(['user', 'komentars']) = 3 query. Kenapa: tiap akses relasi yang belum di-load memicu query baru. Dua relasi berarti dua query susulan per artikel. Pelajarannya: N+1 bukan cuma soal satu relasi. Setiap loop yang menyentuh relasi tanpa eager loading adalah kandidat masalah ini.

Gate: aturan sederhana yang terpusat

Gate cocok untuk aturan yang tidak terikat satu model spesifik. Definisikan di AppServiceProvider:

use Illuminate\Support\Facades\Gate;

Gate::define('akses-admin', function ($user) {
    return $user->is_admin;
});

Memakainya di controller atau route:

if (Gate::denies('akses-admin')) {
    abort(403);
}

Atau langsung di Blade untuk menyembunyikan tombol:

@can('akses-admin')
    <a href="/admin">Panel Admin</a>
@endcan

Policy: aturan per model

Untuk aturan "siapa boleh apa terhadap data ini", Policy lebih rapi. Generate:

php artisan make:policy ArtikelPolicy --model=Artikel
class ArtikelPolicy
{
    public function update(User $user, Artikel $artikel)
    {
        return $user->id === $artikel->user_id;
    }

    public function delete(User $user, Artikel $artikel)
    {
        return $user->id === $artikel->user_id;
    }
}

Logikanya lugas: hanya pemilik (user_id cocok) yang boleh update dan delete. Pakai di controller:

public function edit(Artikel $artikel)
{
    $this->authorize('update', $artikel);
    return view('artikel.edit', compact('artikel'));
}

Kalau tidak berhak, Laravel otomatis melempar 403. Bandingkan dengan if manual yang tersebar: dengan policy, aturannya ditulis sekali di satu file, dipakai di controller, Blade (@can('update', $artikel)), dan konsisten di semua method. Saat aturan berubah ("editor juga boleh edit"), kamu ubah satu tempat.

Kuis cepat: Kapan memakai Gate dan kapan memakai Policy?

Ringkasan cepat

Lima hal untuk diingat: pertama, foreign key selalu tinggal di sisi "many", dan model yang menyimpan foreign key memakai belongsTo. Kedua, uji kebingungan relasi dengan kalimat sehari-hari: "satu komentar punya banyak artikel?" kalau tidak masuk akal, relasinya salah. Ketiga, many-to-many butuh tabel pivot dan belongsToMany di kedua sisi. Keempat, waspadai N+1: setiap akses relasi di dalam loop tanpa with() menambah query, solusinya eager loading. Kelima, bedakan Gate untuk aturan umum ("apakah dia admin?") dan Policy untuk aturan per baris data ("apakah ini miliknya?"). Aturan terpusat di satu tempat selalu lebih aman daripada if tersebar. Sebagai penutup, ingat urutan berpikir yang benar saat merancang fitur baru: struktur data dulu (relasi apa yang dibutuhkan?), lalu siapa boleh apa (policy apa yang dibutuhkan?), baru tulis controller dan view. Kebanyakan kode berantakan lahir dari urutan terbalik: langsung nulis controller, baru sadar relasinya kurang, lalu menambal authorization di akhir. Desain dulu, kode kemudian.

Latihan dan langkah berikutnya

Latihan (jangan cuma dibaca):

  1. Tambahkan kolom user_id ke tabel artikels via migration baru, definisikan relasi User hasMany Artikel dan sebaliknya. Isi beberapa artikel dari dua user berbeda lewat tinker, lalu tampilkan $user->artikels.
  2. Buat halaman daftar artikel yang menampilkan nama penulis tiap artikel. Bandingkan jumlah query dengan dan tanpa with('user') (aktifkan query log atau pakai Laravel Debugbar).
  3. Buat ArtikelPolicy dengan aturan update/delete hanya untuk pemilik. Pasang $this->authorize di method edit, update, destroy. Login sebagai user A, coba edit artikel user B lewat URL langsung, pastikan dapat 403.
  4. Tantangan: tambahkan relasi many-to-many artikel ke tag. Buat form centang tag saat bikin artikel baru.

Episode #79 adalah puncak series Laravel: proyek aplikasi blog mini yang merangkai semuanya. Route, controller, Blade, migration, Eloquent, form, validasi, login, relasi, dan policy, semuanya dipakai dalam satu aplikasi utuh yang bisa kamu tunjukkan ke orang.