Tiga topik ini sering dipelajari terpisah-pisah, padahal ketiganya bicara soal hal yang sama: membuat operasi database kamu benar, aman, dan cepat.

Transaction menjawab: “Bagaimana kalau operasi di tengah jalan gagal?” Indexing menjawab: “Kenapa query ini lambat padahal datanya tidak banyak-banyak amat?” Query Optimization menjawab: “Bagaimana mengambil data sebanyak yang dibutuhkan — tidak lebih, tidak kurang?”

Kita bahas ketiganya satu per satu, dari fondasi sampai praktik nyata.


🔒 Bagian 1 — Database Transaction

Analoginya

Bayangin kamu transfer uang ke teman lewat internet banking. Prosesnya ada dua langkah: saldo kamu dikurangi, lalu saldo temanmu ditambah.

Sekarang bayangin di tengah proses itu — setelah saldo kamu dikurangi tapi sebelum saldo temanmu ditambah — koneksi internet putus. Apa yang terjadi?

Tanpa transaction: uangmu hilang, tapi temanmu tidak menerima apapun.

Dengan transaction: karena langkah kedua gagal, langkah pertama juga dibatalkan. Saldo kamu kembali seperti semula. Tidak ada yang hilang.

Itulah Transaction — sekelompok operasi yang berhasil semuanya atau gagal semuanya. Tidak ada setengah-setengah.


Cara 1 — DB::transaction() (yang paling bersih)

Ini cara yang paling direkomendasikan untuk sebagian besar kasus. Laravel otomatis melakukan commit kalau semua berjalan lancar, dan rollback kalau ada exception.

use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($request) {
    $order = Order::create([
        'user_id' => auth()->id(),
        'total'   => $request->total,
    ]);

    Payment::create([
        'order_id' => $order->id,
        'amount'   => $request->total,
        'status'   => 'pending',
    ]);

    // Kurangi stok produk
    $request->product->decrement('stock', $request->qty);

    // Kalau baris mana pun di atas melempar exception,
    // semua operasi di atas otomatis dibatalkan
});

📌 Kalau ada exception di dalam closure, Laravel memanggil rollback secara otomatis lalu melempar ulang exception-nya. Kamu tidak perlu nulis try-catch sama sekali.


Cara 2 — Manual Transaction (untuk kontrol penuh)

Kadang kamu butuh logika yang lebih granular — misalnya menangani jenis exception yang berbeda secara berbeda, atau melakukan sesuatu sebelum rollback.

DB::beginTransaction();

try {
    $order = Order::create([
        'user_id' => auth()->id(),
        'total'   => $request->total,
    ]);

    Payment::create([
        'order_id' => $order->id,
        'amount'   => $request->total,
    ]);

    $request->product->decrement('stock', $request->qty);

    DB::commit(); // Semua berhasil — simpan permanen

} catch (\Throwable $e) {
    DB::rollBack(); // Ada yang gagal — batalkan semua

    // Log error, kirim notifikasi, atau tangani secara spesifik
    \Log::error('Order gagal dibuat', ['error' => $e->getMessage()]);

    throw $e; // Lempar ulang supaya ditangani di layer atas
}

📌 Selalu gunakan \Throwable bukan \Exception di catch — Throwable menangkap semua error termasuk PHP Fatal Error, sedangkan Exception hanya menangkap exception yang di-throw secara eksplisit.


Yang Sering Dilupakan — Transaction Bukan Berarti Aman dari Segalanya

Transaction melindungi konsistensi data — tapi tidak melindungi dari race condition ketika dua request datang bersamaan.

Contoh nyata: dua pengguna memesan produk terakhir (stok = 1) di waktu yang hampir bersamaan. Keduanya membaca stok = 1, keduanya lolos validasi, keduanya masuk transaction. Hasilnya: stok jadi -1.

Solusinya adalah pessimistic locking — kunci row yang sedang diproses supaya request lain harus menunggu:

DB::transaction(function () use ($productId, $qty) {
    // lockForUpdate() menambahkan FOR UPDATE ke query
    // Row ini dikunci sampai transaction selesai
    $product = Product::lockForUpdate()->findOrFail($productId);

    if ($product->stock < $qty) {
        throw new \Exception('Stok tidak mencukupi');
    }

    $product->decrement('stock', $qty);
    Order::create([...]);
});

📇 Bagian 2 — Indexing

Analoginya

Bayangin kamu punya buku telepon setebal 1.000 halaman dan mau cari nomor “Budi Santoso”.

Tanpa index: kamu baca dari halaman pertama satu per satu sampai ketemu. Ini yang disebut full table scan.

Dengan index: buku telepon diurutkan alfabetis. Kamu langsung buka ke huruf B, cari Budi, selesai dalam hitungan detik.

Index di database bekerja persis seperti itu — struktur data terpisah yang memungkinkan database melompat langsung ke baris yang relevan tanpa membaca seluruh tabel.


Index Dasar — Kolom yang Sering di-WHERE

// Di migration
Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->string('email')->unique(); // otomatis punya index
    $table->string('username');
    $table->timestamps();

    $table->index('username'); // tambahkan index manual
});

Atau tambahkan ke tabel yang sudah ada:

Schema::table('users', function (Blueprint $table) {
    $table->index('username');
});

Kolom yang hampir selalu perlu diindex:


Composite Index — Untuk Query Multi-Kolom

Ini yang paling sering diabaikan, padahal dampaknya besar.

Kalau query kamu sering pakai kombinasi dua kolom sekaligus:

// Query yang sering dijalankan:
Order::where('company_id', $id)->where('status', 'pending')->get();

Membuat dua index terpisah (company_id dan status) tidak optimal — database masih harus menggabungkan hasilnya.

Yang tepat adalah satu composite index:

$table->index(['company_id', 'status']);
// MySQL sekarang bisa langsung loncat ke baris yang tepat

📌 Urutan kolom dalam composite index itu penting. Taruh kolom dengan cardinality tinggi (nilai uniknya banyak) di depan. Dan index ini hanya berguna kalau query menyertakan kolom pertama — WHERE company_id = ? bisa pakai index ini, tapi WHERE status = ? saja tidak bisa.


Jangan Index Semua Kolom

Index bukan gratis — setiap kali ada INSERT, UPDATE, atau DELETE, semua index yang terkait harus diperbarui juga. Terlalu banyak index justru memperlambat operasi tulis.

Kolom yang sebaiknya tidak diindex:


Verifikasi dengan EXPLAIN

Sebelum berasumsi index kamu sudah dipakai, verifikasi dulu:

// Di Tinker atau controller sementara
$query = Order::where('company_id', 1)->where('status', 'pending');
dd(DB::select('EXPLAIN ' . $query->toSql(), $query->getBindings()));

Perhatikan kolom type di output — kalau nilainya ALL, artinya full table scan. Kalau ref atau range, artinya index dipakai.


⚡ Bagian 3 — Query Optimization

Setelah N+1 dibereskan dengan eager loading dan index sudah benar, masih ada level optimasi berikutnya — menulis query yang hanya mengambil apa yang benar-benar dibutuhkan.


Ambil Kolom Spesifik, Bukan SELECT *

SELECT * mengambil semua kolom — termasuk yang tidak kamu pakai sama sekali. Untuk tabel dengan banyak kolom atau kolom teks panjang, ini membuang bandwidth dan memori.

// ❌ Ambil semua kolom — termasuk content artikel yang panjang
$posts = Post::all();

// ✅ Ambil hanya yang dibutuhkan untuk daftar artikel
$posts = Post::select('id', 'title', 'slug', 'created_at')->get();

// Atau dengan with() sekaligus
$posts = Post::select('id', 'title', 'user_id')
             ->with('user:id,name') // user juga hanya ambil id dan name
             ->get();

📌 Ketika pakai with() + select(), pastikan foreign key (user_id) tetap ada di select — tanpa itu, Eloquent tidak bisa menghubungkan relasinya.


pluck() — Kalau Hanya Butuh Satu Kolom

Kalau kamu hanya butuh nilai satu kolom sebagai array, pluck() jauh lebih efisien dari get():

// ❌ Ambil semua kolom hanya untuk diambil name-nya
$names = User::all()->pluck('name');

// ✅ Query langsung SELECT name FROM users
$names = User::pluck('name');

// Dengan key → value
$users = User::pluck('name', 'id');
// Hasil: [1 => 'Budi', 2 => 'Sari', ...]

withCount() — Hitung Relasi Tanpa Muat Datanya

Sering butuh menampilkan jumlah komentar per post tapi tidak butuh isi komentarnya? Jangan muat semua komentar hanya untuk dihitung.

// ❌ Muat semua komentar hanya untuk count
$posts = Post::with('comments')->get();
foreach ($posts as $post) {
    echo $post->comments->count(); // sudah termuat semua datanya
}

// ✅ Hanya tambahkan kolom hitungan — tidak muat data komentar
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
    echo $post->comments_count; // satu integer, bukan koleksi
}

// Bisa dikombinasikan
$posts = Post::withCount(['comments', 'likes'])->get();

Di balik layar, withCount() menambahkan subquery COUNT(*) — jauh lebih ringan dari memuat semua data relasi.


whereHas() — Filter Berdasarkan Kondisi Relasi

Untuk mengambil data yang punya relasi tertentu dengan kondisi spesifik:

// Post yang punya minimal satu komentar yang disetujui
$posts = Post::whereHas('comments', function ($query) {
    $query->where('is_approved', true);
})->get();

// User yang punya lebih dari 5 post
$users = User::whereHas('posts', function ($query) {
    // filter kondisi di sini
}, '>', 5)->get();

// Kebalikannya — post yang tidak punya komentar sama sekali
$posts = Post::doesntHave('comments')->get();

Cache — Untuk Query yang Hasilnya Jarang Berubah

Kalau kamu punya query yang berat tapi hasilnya jarang berubah (misalnya daftar kategori, konfigurasi aplikasi, statistik harian), simpan hasilnya di cache — jangan hit database setiap request.

use Illuminate\Support\Facades\Cache;

// Ambil dari cache kalau ada, jalankan query kalau tidak ada
$categories = Cache::remember('categories', now()->addHours(6), function () {
    return Category::where('is_active', true)
                   ->orderBy('sort_order')
                   ->get();
});

Ketika data berubah, invalidasi cache-nya:

// Di Observer atau setelah operasi update
Cache::forget('categories');

// Atau lebih aman: hapus dan isi ulang sekaligus
Cache::put('categories', Category::where('is_active', true)->get(), now()->addHours(6));

📌 Tiga pertanyaan sebelum pasang cache: Apa yang mahal? Key apa yang dipakai? Kapan harus expired atau diinvalidasi? Kalau belum tahu jawabannya — jangan cache dulu.


🗺️ Gambaran Besar — Urutan Optimasi yang Benar

Jangan loncat langsung ke cache kalau masalah dasarnya belum beres.

Urutan yang benar:

1. Bereskan N+1 dulu
   └── Pakai with(), load()

2. Pastikan index sudah benar
   └── Terutama kolom WHERE dan foreign key
   └── Composite index untuk query multi-kolom

3. Ambil hanya yang dibutuhkan
   └── select() kolom spesifik
   └── pluck() untuk satu kolom
   └── withCount() untuk agregasi
   └── whereHas() untuk filter relasi

4. Pakai transaction untuk operasi multi-langkah
   └── DB::transaction() untuk kasus umum
   └── lockForUpdate() untuk cegah race condition

5. Cache kalau query berat dan data jarang berubah
   └── Cache::remember() dengan TTL yang masuk akal
   └── Invalidasi cache saat data diupdate

📊 Ringkasan Cepat

TeknikMasalah yang Diselesaikan
DB::transaction()Konsistensi — semua atau tidak sama sekali
lockForUpdate()Race condition pada data yang diperebutkan
index()Query lambat karena full table scan
index(['col1', 'col2'])Query multi-kolom yang lambat
select('col1', 'col2')Terlalu banyak data dibawa ke memori
pluck('col')Hanya butuh satu kolom sebagai array
withCount('relation')Hitung relasi tanpa muat seluruh datanya
whereHas('relation')Filter berdasarkan kondisi di relasi
Cache::remember()Query berat yang hasilnya jarang berubah

💡 Ingat!

Dari semua teknik di atas, ada satu yang paling sering diabaikan dan paling mudah dilupakan: composite index.

Banyak developer sudah rajin index kolom satu-satu, tapi query yang pakai kombinasi dua kolom tetap lambat karena tidak ada composite index-nya. Setiap kali kamu nulis where()->where() di query yang sering dieksekusi, tanyakan: “Apakah dua kolom ini punya composite index?”

Dan untuk transaction — satu aturan sederhana yang cukup: kalau satu operasi gagal dan itu berarti operasi lain tidak boleh jadi, pakai transaction. Tidak perlu overthinking kapan harus pakai, cukup tanyakan pertanyaan itu.

Ada jenis bug yang paling berbahaya bukan karena dia crash, tapi karena dia diam-diam memperlambat aplikasimu dan baru terasa ketika sudah terlambat.

N+1 Problem adalah salah satunya.

Kodenya terlihat benar. Hasilnya benar. Tapi di baliknya, database sedang dibombardir ratusan query yang tidak perlu — dan kamu tidak sadar sampai ada pengguna mengeluh halaman lambat, atau sampai server kehabisan koneksi.

Di artikel ini kita bahas: apa itu N+1, bagaimana mendeteksinya sebelum sampai production, dan semua cara memperbaikinya.


🤔 Apa Itu N+1 Problem?

Analoginya

Bayangin kamu punya daftar 100 nama murid, dan kamu mau tahu nilai ujian masing-masing.

Cara bodoh: pergi ke lemari arsip, ambil map murid pertama, baca nilainya, kembalikan. Pergi lagi, ambil map murid kedua, baca nilainya, kembalikan. Ulangi 100 kali.

Cara pintar: ambil semua 100 map sekaligus, baca semuanya dalam satu dudukan.

Cara bodoh itu yang terjadi di N+1 Problem — satu query untuk daftarnya, lalu satu query lagi untuk setiap item dalam daftar.

Dalam kode

$posts = Post::all(); // Query #1: SELECT * FROM posts

foreach ($posts as $post) {
    echo $post->user->name;
    // Query #2, #3, #4, ... #N+1: SELECT * FROM users WHERE id = ?
}

Kalau ada 100 post → 101 query. Kalau ada 1.000 post → 1.001 query.

Ini bukan teori. Di aplikasi nyata dengan data yang cukup besar, ini bisa membuat satu halaman membutuhkan ribuan query hanya untuk di-render.


🔍 Cara Deteksi N+1 Problem

Memperbaiki N+1 yang sudah ada di production adalah situasi yang mau kita hindari. Lebih baik deteksi saat development.

Cara 1 — Laravel Debugbar

Package paling populer untuk ini. Install sekali, langsung terlihat semua query yang dijalankan di setiap request — lengkap dengan berapa kali query yang sama diulang.

composer require barryvdh/laravel-debugbar --dev

Setelah terpasang, buka aplikasimu di browser. Di bagian bawah halaman muncul toolbar — klik tab Queries. Kalau kamu lihat query yang sama berulang puluhan kali dengan ID yang berbeda-beda, itu N+1.

# Tanda bahaya yang terlihat di Debugbar:
SELECT * FROM users WHERE id = 1    ← ini normal
SELECT * FROM users WHERE id = 2    ← mulai curiga
SELECT * FROM users WHERE id = 3    ← N+1
SELECT * FROM users WHERE id = 4    ← N+1
... (95 query lagi)

Cara 2 — preventLazyLoading()

Ini cara yang lebih agresif dan sangat disarankan untuk environment development. Laravel akan melempar exception setiap kali relasi diakses tanpa eager loading — jadi N+1 tidak bisa lolos tanpa disadari.

📁 app/Providers/AppServiceProvider.php

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // Aktifkan hanya di environment development
    Model::preventLazyLoading(! app()->isProduction());
}

Sekarang kalau ada kode yang memicu N+1, kamu akan mendapat error yang jelas:

Illuminate\Database\LazyLoadingViolationException:
Attempted to lazy load [user] on model [App\Models\Post]
but lazy loading is disabled.

📌 Ini adalah net safety yang sangat berguna. Kamu tidak bisa tidak sadar — aplikasi langsung protes sebelum masalahnya sampai ke production.

Cara 3 — DB::listen()

Untuk debugging manual yang lebih granular, kamu bisa log semua query yang berjalan:

// Taruh sementara di controller atau tinker
\DB::listen(function ($query) {
    \Log::info($query->sql, $query->bindings);
});

Atau pakai toSql() untuk melihat query yang akan dihasilkan sebelum dieksekusi:

dd(Post::with('user')->toSql());
// "select * from `posts`"

💡 Solusi 1 — Eager Loading dengan with()

Ini solusi utama dan paling sering dipakai. Daripada memuat relasi saat diperlukan (lazy), kamu minta Laravel untuk memuat semuanya sekaligus di awal (eager).

// ❌ Sebelum — 101 query untuk 100 post
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->user->name;
}

// ✅ Sesudah — selalu 2 query, berapapun jumlah post
$posts = Post::with('user')->get();
foreach ($posts as $post) {
    echo $post->user->name;
}

Di balik layar, Laravel menjalankan dua query yang efisien:

-- Query 1: ambil semua post
SELECT * FROM posts;

-- Query 2: ambil semua user yang relevan sekaligus
SELECT * FROM users WHERE id IN (1, 2, 3, 4, ...);

Eager loading banyak relasi sekaligus

// Load beberapa relasi dalam satu panggilan
$posts = Post::with(['user', 'comments', 'tags'])->get();

Eager loading relasi bersarang (nested)

// Load komentar, dan setiap komentar juga load usernya
$posts = Post::with('comments.user')->get();

Eager loading dengan kondisi

// Hanya load komentar yang sudah disetujui
$posts = Post::with(['comments' => function ($query) {
    $query->where('is_approved', true)->orderBy('created_at', 'desc');
}])->get();

💡 Solusi 2 — Lazy Eager Loading dengan load()

Kadang kamu sudah terlanjur punya koleksi model — mungkin datang dari method lain, atau kondisinya baru diketahui setelah data diambil. Di sini kamu tidak bisa pakai with() lagi, tapi masih bisa pakai load().

// Data sudah ada di tangan
$posts = Post::all();

// Baru sadar butuh relasi — load sekarang
// Tetap hanya 1 query tambahan, bukan N query
$posts->load('user');

// Atau banyak relasi sekaligus
$posts->load(['user', 'comments']);

📌 load() berbeda dari akses lazy biasa ($post->user). load() dipanggil pada koleksi — hasilnya satu query untuk semua. Sedangkan $post->user di dalam loop menghasilkan query per item.


💡 Solusi 3 — Chunking untuk Dataset Besar

Eager loading memang solusi N+1, tapi ada masalah lain yang muncul ketika datanya sangat besar: memori.

Post::with('user')->get() akan muat semua data ke memori sekaligus. Kalau ada 500.000 post, kamu bisa kehabisan memory limit PHP sebelum sempat memproses apapun.

Di sinilah chunk() masuk.

chunk() — Proses per batch

// Proses 200 post sekaligus, tidak pernah lebih dari itu di memori
Post::with('user')->chunk(200, function ($posts) {
    foreach ($posts as $post) {
        // proses tiap post
        // misalnya: kirim notifikasi, generate laporan, export CSV
    }
});

Di balik layar, Laravel menjalankan query dengan LIMIT dan OFFSET yang bergeser setiap batch:

SELECT * FROM posts LIMIT 200 OFFSET 0;
SELECT * FROM posts LIMIT 200 OFFSET 200;
SELECT * FROM posts LIMIT 200 OFFSET 400;
-- dan seterusnya sampai habis

chunkById() — Lebih aman untuk data yang berubah

chunk() punya satu kelemahan: kalau ada data yang dihapus atau diubah di tengah proses chunking, offset bisa meleset — beberapa record bisa terlewat.

chunkById() tidak pakai offset, tapi pakai WHERE id > ? — lebih aman:

Post::with('user')->chunkById(200, function ($posts) {
    foreach ($posts as $post) {
        // aman meski ada data yang berubah di tengah proses
    }
});

lazy() — Alternatif modern yang lebih elegan

Laravel juga punya lazy() dan lazyById() — bekerja seperti chunk tapi kamu bisa pakai sintaks foreach biasa tanpa callback:

// Terasa seperti iterasi biasa, tapi di baliknya tetap pakai chunking
foreach (Post::with('user')->lazy() as $post) {
    // proses tiap post
}

📌 lazy() menggunakan PHP Generator di baliknya — memori tetap efisien karena hanya satu batch yang ada di memori setiap saat.


🗺️ Gambaran Besar — Pilih Solusi yang Tepat

Data yang diambil
│
├── Ukuran normal (ratusan hingga ribuan record)
│    │
│    ├── Belum punya data → with('relasi')->get()
│    │
│    └── Sudah punya koleksi → $koleksi->load('relasi')
│
└── Dataset sangat besar (puluhan ribu ke atas)
     │
     ├── Butuh callback per batch → chunk() / chunkById()
     │
     └── Mau sintaks foreach biasa → lazy() / lazyById()

📊 Perbandingan Solusi

MetodeJumlah QueryMemoriCocok untuk
Lazy (default)N+1 ❌Rendah— hindari dalam loop
with()2 query ✅Tinggi (semua dimuat)Dataset normal
load()2 query ✅Tinggi (semua dimuat)Koleksi yang sudah ada
chunk()Per batch ✅Rendah ✅Dataset besar
chunkById()Per batch ✅Rendah ✅Dataset besar + data bisa berubah
lazy()Per batch ✅Rendah ✅Dataset besar, sintaks bersih

💡 Ingat!

N+1 Problem hampir tidak pernah terlihat di data kecil — itulah yang membuatnya berbahaya. Di local development dengan 10 record, semua terasa cepat. Di production dengan 10.000 record, halaman yang sama bisa timeout.

Dua kebiasaan yang kalau diterapkan dari awal akan menyelamatkan banyak waktu debugging:

Pertama, aktifkan Model::preventLazyLoading() di environment development. Biarkan Laravel yang berteriak duluan, bukan pengguna di production.

Kedua, biasakan selalu tanya pada diri sendiri ketika menulis loop Eloquent: “Apakah saya mengakses relasi di dalam loop ini?” Kalau iya — with() dulu sebelum loop dimulai.

Itu saja. Dua kebiasaan kecil, efeknya besar.

Salah satu hal yang bikin Laravel terasa menyenangkan adalah Eloquent. Kamu tidak perlu nulis JOIN manual — cukup definisikan hubungan antar model, dan Laravel yang urus sisanya.

Tapi ada tujuh jenis relasi, dan masing-masing punya konteks pemakaian yang berbeda. Kalau dipelajari satu-satu tanpa gambaran besarnya, mudah sekali bingung — terutama ketika sampai di bagian polymorphic.

Jadi kita mulai dari yang paling simpel, naik pelan-pelan ke yang paling kompleks. Untuk setiap relasi, kita pakai satu kasus nyata yang konkret supaya mudah dibayangkan.


🧭 Peta Relasi Sebelum Mulai

Sebelum masuk ke kode, ini gambaran besar ketujuh relasi berdasarkan strukturnya:

Satu ke Satu           hasOne / belongsTo
Satu ke Banyak         hasMany / belongsTo
Banyak ke Banyak       belongsToMany (dua arah)
Satu ke Banyak Lewat   hasManyThrough
Polymorphic 1:1 / 1:N  morphOne / morphMany / morphTo
Polymorphic N:N        morphToMany / morphedByMany

1️⃣ One to One — hasOne & belongsTo

Kasus nyata

Satu User punya tepat satu Profile. Satu Profile hanya milik satu User.

Ini biasanya terjadi ketika kamu memisahkan data autentikasi (email, password) dari data profil (bio, avatar, nomor telepon) — dua concern yang berbeda, jadi dua tabel yang berbeda.

Struktur tabel

users
  id (PK)
  email

profiles
  id (PK)
  user_id (FK → users.id)   ← foreign key ada di sisi "anak"
  bio
  avatar

Kode model

📁 app/Models/User.php

class User extends Model
{
    // "Saya punya satu profile di tabel lain"
    public function profile(): HasOne
    {
        return $this->hasOne(Profile::class);
    }
}

📁 app/Models/Profile.php

class Profile extends Model
{
    // "Saya punya foreign key — saya milik seorang User"
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

Cara pakainya

// Ambil profile dari user
$profile = User::find(1)->profile;

// Ambil user pemilik profile
$user = Profile::find(1)->user;

📌 Aturan mudah: Foreign key (user_id) selalu ada di tabel yang belongsTo. Kalau bingung mana yang hasOne dan mana yang belongsTo — cari tabel mana yang punya kolom _id-nya, itulah yang belongsTo.


2️⃣ One to Many — hasMany & belongsTo

Kasus nyata

Satu Post bisa punya banyak Comment. Tapi satu Comment hanya milik satu Post.

Ini relasi paling umum. Hampir semua entitas “induk–anak” cocok dengan pola ini: User → Orders, Category → Products, Invoice → Items.

Struktur tabel

posts
  id (PK)
  title

comments
  id (PK)
  post_id (FK → posts.id)   ← foreign key tetap di sisi "anak"
  body
  user_id

Kode model

📁 app/Models/Post.php

class Post extends Model
{
    // "Saya punya banyak komentar"
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }
}

📁 app/Models/Comment.php

class Comment extends Model
{
    // "Saya milik sebuah Post"
    public function post(): BelongsTo
    {
        return $this->belongsTo(Post::class);
    }
}

Cara pakainya

// Semua komentar dari sebuah post
$comments = Post::find(1)->comments;

// Post mana yang dimiliki komentar ini
$post = Comment::find(1)->post;

// Buat komentar baru langsung lewat relasi
Post::find(1)->comments()->create([
    'body'    => 'Artikel yang bagus!',
    'user_id' => auth()->id(),
]);

📌 Perbedaan ->comments (tanpa kurung) dan ->comments() (dengan kurung): yang pertama langsung mengembalikan koleksi, yang kedua mengembalikan query builder — bisa di-chain dengan where(), orderBy(), dll.


3️⃣ Many to Many — belongsToMany

Kasus nyata

Seorang User bisa punya banyak Role (admin, editor, viewer). Dan satu Role bisa dimiliki oleh banyak User.

Ini tidak bisa diselesaikan hanya dengan dua tabel — karena tidak ada yang bisa menaruh foreign key di satu pihak saja. Dibutuhkan tabel pivot sebagai perantara.

Struktur tabel

users
  id (PK)

roles
  id (PK)
  name

role_user          ← tabel pivot (urutan alfabetis, snake_case)
  user_id (FK → users.id)
  role_id (FK → roles.id)

Kode model

📁 app/Models/User.php

class User extends Model
{
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class);
        // Laravel otomatis cari tabel pivot 'role_user'
    }
}

📁 app/Models/Role.php

class Role extends Model
{
    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class);
    }
}

Cara pakainya

$user = User::find(1);

// Lampirkan role ke user (INSERT ke tabel pivot)
$user->roles()->attach($roleId);

// Lepas role dari user (DELETE dari tabel pivot)
$user->roles()->detach($roleId);

// Sinkronisasi — setel ulang pivot agar hanya berisi ID ini
// Role yang tidak ada di array akan otomatis dilepas
$user->roles()->sync([1, 3, 5]);

// Ambil semua role milik user
foreach ($user->roles as $role) {
    echo $role->name;
}

📌 Kalau tabel pivot punya kolom tambahan (misalnya assigned_at, level), akses lewat properti pivot:

// Definisikan kolom ekstra di model
return $this->belongsToMany(Role::class)->withPivot('assigned_at');

// Akses nilainya
echo $user->roles->first()->pivot->assigned_at;

4️⃣ Has Many Through — hasManyThrough

Kasus nyata

Sebuah Country ingin mengambil semua Post yang ditulis oleh warganya. Tapi Country tidak punya relasi langsung ke Post — hanya punya relasi ke User, dan User yang punya relasi ke Post.

Ini yang disebut relasi “lewat” — kamu butuh model perantara untuk menyambungkan dua model yang tidak terhubung langsung.

Struktur tabel

countries
  id (PK)
  name

users
  id (PK)
  country_id (FK → countries.id)

posts
  id (PK)
  user_id (FK → users.id)
  title

Kode model

📁 app/Models/Country.php

class Country extends Model
{
    // "Saya punya banyak Post, lewat User"
    public function posts(): HasManyThrough
    {
        return $this->hasManyThrough(
            Post::class,   // model tujuan akhir
            User::class    // model perantara
        );
    }
}

Cara pakainya

// Semua post dari user yang berasal dari Indonesia
$posts = Country::where('name', 'Indonesia')->first()->posts;

📌 hasManyThrough tidak butuh kode tambahan di model User atau Post. Cukup definisikan di model Country, Laravel yang urus query-nya secara otomatis.


5️⃣ Polymorphic One to Many — morphMany & morphTo

Kasus nyata

Fitur komentar di aplikasimu bisa menempel ke Post maupun ke Video. Kamu tidak mau bikin tabel post_comments dan video_comments yang strukturnya identik — itu duplikasi.

Polymorphic adalah solusinya: satu tabel comments yang bisa “menempel” ke model mana saja.

Struktur tabel

comments
  id (PK)
  body
  commentable_id    ← ID dari model induknya (Post atau Video)
  commentable_type  ← nama class model induknya ('App\Models\Post')

Kode model

📁 app/Models/Post.php

class Post extends Model
{
    // "Saya bisa punya banyak komentar (polymorphic)"
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

📁 app/Models/Video.php

class Video extends Model
{
    // Persis sama — polymorphic bisa ditempel ke model apa saja
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

📁 app/Models/Comment.php

class Comment extends Model
{
    // "Saya bisa milik model apa saja — Post, Video, atau yang lain"
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }
}

Cara pakainya

// Tambah komentar ke post
Post::find(1)->comments()->create(['body' => 'Mantap!']);

// Tambah komentar ke video — kode yang sama persis
Video::find(1)->comments()->create(['body' => 'Keren!']);

// Dari komentar, ambil model induknya (entah Post atau Video)
$comment = Comment::find(1);
$parent = $comment->commentable; // bisa berupa Post atau Video

📌 commentable adalah nama bebas — itu yang disebut morph name. Harus konsisten antara nama di kolom tabel (commentable_id, commentable_type) dan nama yang dipakai di morphMany('commentable') dan morphTo().


6️⃣ Polymorphic Many to Many — morphToMany & morphedByMany

Kasus nyata

Fitur Tag di aplikasimu bisa dipakai di Post, Video, maupun Product. Dan satu konten bisa punya banyak tag. Ini Many-to-Many, tapi polymorphic — karena tag bisa menempel ke model yang berbeda-beda.

Struktur tabel

tags
  id (PK)
  name

taggables          ← tabel pivot polymorphic
  tag_id (FK → tags.id)
  taggable_id      ← ID dari Post, Video, atau Product
  taggable_type    ← nama class-nya

Kode model

📁 app/Models/Post.php

class Post extends Model
{
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

📁 app/Models/Tag.php

class Tag extends Model
{
    // Dari Tag, ambil semua Post yang pakai tag ini
    public function posts(): MorphedByMany
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }

    // Dari Tag, ambil semua Video yang pakai tag ini
    public function videos(): MorphedByMany
    {
        return $this->morphedByMany(Video::class, 'taggable');
    }
}

Cara pakainya

// Tempel tag ke post
Post::find(1)->tags()->attach($tagId);

// Ambil semua post dengan tag tertentu
$posts = Tag::where('name', 'laravel')->first()->posts;

🗺️ Gambaran Besar — Kapan Pakai Yang Mana

Pertanyaan pertama: berapa "banyak" di setiap sisi?
│
├── Satu ↔ Satu
│    └── hasOne + belongsTo
│        Contoh: User ↔ Profile
│
├── Satu ↔ Banyak
│    └── hasMany + belongsTo
│        Contoh: Post → Comments
│
├── Banyak ↔ Banyak
│    └── belongsToMany (butuh tabel pivot)
│        Contoh: User ↔ Roles
│
├── Tidak ada relasi langsung, lewat model lain
│    └── hasManyThrough
│        Contoh: Country → User → Posts
│
└── Relasi bisa menempel ke banyak model berbeda?
     │
     ├── Satu / Banyak → morphOne / morphMany + morphTo
     │   Contoh: Post & Video → Comments
     │
     └── Banyak ↔ Banyak → morphToMany + morphedByMany
         Contoh: Post & Video & Product ↔ Tags

📊 Perbandingan Singkat

RelasiMethodKebutuhan Tabel TambahanPolymorphic
One to OnehasOne / belongsTo
One to ManyhasMany / belongsTo
Many to ManybelongsToMany✅ pivot table
Has Many ThroughhasManyThrough
Poly One to ManymorphMany / morphTo❌ (2 kolom ekstra)
Poly Many to ManymorphToMany / morphedByMany✅ pivot polymorphic

💡 Ingat!

Dua hal yang paling sering bikin bingung di relasi Eloquent:

PertamabelongsTo selalu ada di model yang punya foreign key di tabelnya. Kalau kamu lihat kolom user_id di tabel posts, maka Post yang punya belongsTo(User::class) — bukan sebaliknya.

Kedua — Polymorphic bukan sesuatu yang perlu dipaksakan di setiap kasus. Pakai polymorphic hanya ketika satu entitas benar-benar perlu “menempel” ke beberapa model berbeda secara bersamaan. Kalau tidak ada kebutuhan itu, relasi biasa jauh lebih mudah dibaca dan di-debug.

Dan satu lagi — selalu gunakan eager loading (with()) ketika mengambil relasi dalam loop. Topik itu layak artikel sendiri, tapi intinya: Post::with('comments')->get() jauh lebih baik daripada memanggil $post->comments di dalam foreach.

Kalau kamu sudah cukup lama nulis kode Laravel, pasti pernah sampai di titik di mana kamu buka file controller dan merasa — “ini kok jadi novel?”

Ratusan baris. Validasi di sini. Query Eloquent di sana. Kirim email di bawah. Hitung diskon di tengah. Semua dalam satu method store().

Itulah yang disebut Fat Controller — dan ini salah satu “dosa” paling umum di aplikasi Laravel yang sudah berkembang.

Di artikel ini, kita bahas cara membereskannya dengan dua pendekatan yang paling umum dipakai: Service Layer dan Action Class.


🤔 Masalah dengan Fat Controller

Sebelum masuk ke solusinya, penting untuk ngerti dulu kenapa Fat Controller itu menjadi masalah — bukan hanya soal estetika kode.

Bayangin controller ini:

class OrderController extends Controller
{
    public function store(Request $request)
    {
        // Validasi
        $request->validate([
            'product_id' => 'required|exists:products,id',
            'quantity'   => 'required|integer|min:1',
        ]);

        // Cek stok
        $product = Product::findOrFail($request->product_id);
        if ($product->stock < $request->quantity) {
            return response()->json(['message' => 'Stok tidak cukup'], 422);
        }

        // Hitung harga
        $subtotal = $product->price * $request->quantity;
        $discount = $subtotal > 500000 ? $subtotal * 0.1 : 0;
        $total = $subtotal - $discount;

        // Buat order
        $order = Order::create([
            'user_id'    => auth()->id(),
            'product_id' => $product->id,
            'quantity'   => $request->quantity,
            'total'      => $total,
        ]);

        // Kurangi stok
        $product->decrement('stock', $request->quantity);

        // Kirim email konfirmasi
        Mail::to(auth()->user())->send(new OrderConfirmation($order));

        return response()->json($order, 201);
    }
}

Kodenya bekerja. Tapi ada masalah yang tidak terlihat langsung:

Prinsip Single Responsibility bilang: satu kelas, satu alasan untuk berubah. Controller di atas punya setidaknya lima alasan berbeda untuk berubah.


🏗️ Service Layer — “Kelas Pembantu yang Spesialis”

Analoginya

Bayangin controller seperti resepsionis hotel.

Tugasnya adalah: terima tamu, arahkan ke tempat yang tepat, berikan respon. Bukan tugasnya untuk memasak makanan, membersihkan kamar, atau mengurus pembukuan.

Untuk hal-hal itu, resepsionis memanggil spesialis — dapur, housekeeping, akunting. Masing-masing ahli di bidangnya sendiri.

Service Layer adalah spesialis itu. Controller cukup memanggil mereka, tidak perlu tahu cara kerjanya.

Strukturnya

app/
├── Http/
│   └── Controllers/
│       └── OrderController.php   ← Resepsionis: terima request, panggil service, return response
│
├── Services/
│   └── OrderService.php          ← Spesialis: semua logika bisnis order

Kodenya

📁 app/Services/OrderService.php

Semua logika bisnis dipindahkan ke sini. Controller tidak perlu tahu cara kerjanya — cukup panggil method-nya.

<?php

namespace App\Services;

use App\Models\Order;
use App\Models\Product;
use App\Mail\OrderConfirmation;
use Illuminate\Support\Facades\Mail;

class OrderService
{
    public function createOrder(array $data, int $userId): Order
    {
        $product = Product::findOrFail($data['product_id']);

        // Cek stok
        if ($product->stock < $data['quantity']) {
            throw new \Exception('Stok tidak cukup');
        }

        // Hitung harga — logika ini sekarang mudah dites secara terisolasi
        $total = $this->calculateTotal($product->price, $data['quantity']);

        // Buat order
        $order = Order::create([
            'user_id'    => $userId,
            'product_id' => $product->id,
            'quantity'   => $data['quantity'],
            'total'      => $total,
        ]);

        // Kurangi stok
        $product->decrement('stock', $data['quantity']);

        // Kirim notifikasi
        Mail::to(auth()->user())->send(new OrderConfirmation($order));

        return $order;
    }

    private function calculateTotal(int $price, int $quantity): int
    {
        $subtotal = $price * $quantity;
        $discount = $subtotal > 500000 ? $subtotal * 0.1 : 0;

        return $subtotal - $discount;
    }
}

📁 app/Http/Controllers/OrderController.php

Controller sekarang cukup tiga hal: terima request → panggil service → return response.

<?php

namespace App\Http\Controllers;

use App\Services\OrderService;
use Illuminate\Http\Request;

class OrderController extends Controller
{
    public function __construct(
        private OrderService $orderService
    ) {}

    public function store(Request $request)
    {
        $request->validate([
            'product_id' => 'required|exists:products,id',
            'quantity'   => 'required|integer|min:1',
        ]);

        $order = $this->orderService->createOrder(
            $request->only('product_id', 'quantity'),
            auth()->id()
        );

        return response()->json($order, 201);
    }
}

📌 Bandingkan controller ini dengan versi sebelumnya. Yang tersisa hanya koordinasi — validasi input, panggil yang bertanggung jawab, kembalikan hasilnya. Tidak lebih.


⚡ Action Class — “Satu Kelas, Satu Tugas”

Analoginya

Service Layer seperti toko servis elektronik yang bisa memperbaiki TV, AC, dan kulkas. Satu toko, banyak keahlian.

Action Class seperti tukang spesialis yang hanya bisa satu hal tapi melakukannya dengan sempurna.

Ada CreateOrderAction — tugasnya satu: membuat order. Ada CancelOrderAction — tugasnya satu: membatalkan order. Ada RefundOrderAction — tugasnya satu: memproses refund.

Masing-masing kelas kecil, fokus, dan sangat mudah dibaca.

Strukturnya

app/
├── Http/
│   └── Controllers/
│       └── OrderController.php
│
├── Actions/
│   ├── CreateOrderAction.php     ← hanya tahu cara membuat order
│   ├── CancelOrderAction.php     ← hanya tahu cara membatalkan order
│   └── RefundOrderAction.php     ← hanya tahu cara memproses refund

Kodenya

📁 app/Actions/CreateOrderAction.php

Konvensi umum: satu Action punya satu method publik, sering dinamai execute() atau handle().

<?php

namespace App\Actions;

use App\Models\Order;
use App\Models\Product;
use App\Mail\OrderConfirmation;
use Illuminate\Support\Facades\Mail;

class CreateOrderAction
{
    // Satu method publik — itulah satu-satunya yang dilakukan kelas ini
    public function execute(array $data, int $userId): Order
    {
        $product = Product::findOrFail($data['product_id']);

        if ($product->stock < $data['quantity']) {
            throw new \Exception('Stok tidak cukup');
        }

        $subtotal = $product->price * $data['quantity'];
        $discount = $subtotal > 500000 ? $subtotal * 0.1 : 0;

        $order = Order::create([
            'user_id'    => $userId,
            'product_id' => $product->id,
            'quantity'   => $data['quantity'],
            'total'      => $subtotal - $discount,
        ]);

        $product->decrement('stock', $data['quantity']);
        Mail::to(auth()->user())->send(new OrderConfirmation($order));

        return $order;
    }
}

📁 app/Http/Controllers/OrderController.php

<?php

namespace App\Http\Controllers;

use App\Actions\CreateOrderAction;
use Illuminate\Http\Request;

class OrderController extends Controller
{
    public function store(Request $request, CreateOrderAction $action)
    {
        $request->validate([
            'product_id' => 'required|exists:products,id',
            'quantity'   => 'required|integer|min:1',
        ]);

        $order = $action->execute(
            $request->only('product_id', 'quantity'),
            auth()->id()
        );

        return response()->json($order, 201);
    }
}

📌 Perhatikan CreateOrderAction di-inject langsung ke method — bukan di constructor. Ini method injection, dan cocok untuk action karena setiap endpoint controller biasanya butuh action yang berbeda.


🗺️ Gambaran Besar — Posisi Masing-Masing

Request masuk
      ↓
┌─────────────────────────────────────────────┐
│  Controller  (tipis — hanya koordinasi)     │
│  • Terima input                             │
│  • Validasi format                          │
│  • Panggil Service / Action                 │
│  • Return response                          │
└──────────────┬──────────────────────────────┘
               │
    ┌──────────┴──────────┐
    ▼                     ▼
Service Layer         Action Class
(banyak method,       (satu method,
 satu domain)          satu tugas)
    │                     │
    └──────────┬──────────┘
               ▼
        Model / Database

🆚 Service Layer vs Action Class — Mana yang Lebih Baik?

Tidak ada yang lebih baik secara mutlak. Keduanya punya tempat masing-masing.

Service Layer lebih cocok ketika: Beberapa operasi dalam satu domain saling berbagi logika atau state. Misalnya OrderService punya createOrder(), calculateTotal(), dan checkStock() — ketiganya erat kaitannya dan sering dipakai bersama.

// Satu service, banyak method yang saling berkaitan
class OrderService
{
    public function createOrder(...) { ... }
    public function cancelOrder(...) { ... }
    public function getOrderHistory(...) { ... }
}

Action Class lebih cocok ketika: Setiap operasi berdiri sendiri dan tidak butuh berbagi state dengan operasi lain. Lebih mudah dibaca karena nama file-nya langsung menjelaskan apa yang dilakukan.

// Satu file, satu tugas, nama yang berbicara sendiri
CreateOrderAction.php
CancelOrderAction.php
ProcessRefundAction.php

🧭 Jadi, Kapan Pakai Yang Mana?

Pakai Service Layer ketika: Kamu punya satu domain (misalnya Order, Payment, User) dengan banyak operasi yang saling berkaitan, atau ada logika yang perlu dibagi antar beberapa method.

Pakai Action Class ketika: Operasi-operasi dalam satu domain cenderung berdiri sendiri, dan kamu ingin setiap file punya satu tanggung jawab yang sangat jelas. Lebih mudah dinavigasi di proyek besar.

Pakai keduanya ketika: Action Class menangani satu operasi spesifik, tapi di dalamnya boleh memanggil Service jika butuh logika bersama. Tidak ada aturan yang bilang keduanya tidak boleh koeksistensi.


📊 Perbandingan Singkat

Fat ControllerService LayerAction Class
Ukuran fileBesarSedangKecil
Tanggung jawabBanyakPer domainPer operasi
Reusability
Mudah dites
Navigasi di proyek besarSusahLumayanMudah
Cocok untukPrototipe cepatLogika domain kompleksOperasi mandiri

💡 Ingat!

Thin Controller bukan berarti controller yang bodoh — dia tetap bertanggung jawab atas dua hal: memahami HTTP (request, response, status code) dan mendelegasikan ke lapisan yang tepat.

Yang dipindahkan ke Service atau Action adalah logika bisnis — aturan-aturan yang menentukan bagaimana aplikasi bekerja. Logika itu tidak perlu tahu apakah dia dipanggil dari HTTP request, Artisan command, atau job di background.

Dan itu justru kekuatan terbesarnya: sekali logika bisnis terpisah dari controller, kamu bisa memanggilnya dari mana saja — tanpa ubah satu baris pun di dalamnya.

Kalau kamu baru mulai pakai Laravel, salah satu hal pertama yang bikin kagum adalah betapa singkatnya mendaftarkan route CRUD. Satu baris, tujuh route langsung jadi.

Tapi begitu mulai bangun API, kamu ketemu apiResource() — dan muncul pertanyaan: “Apa bedanya? Kapan pakai yang mana?”

Jawabannya sebetulnya simpel. Tapi supaya benar-benar ngerti kenapa perbedaan itu ada, kita mulai dari konteksnya dulu.


🤔 Masalah yang Mereka Selesaikan

Bayangin kamu lagi bangun aplikasi blog. Ada fitur untuk membuat, mengedit, dan menghapus artikel.

Kalau kamu bangun website biasa (server-side rendered, pakai Blade), pengguna butuh:

Dua halaman itu — form create dan form edit — adalah HTML yang di-render oleh Laravel, dikembalikan sebagai response lengkap ke browser.

Tapi kalau kamu bangun REST API yang dikonsumsi frontend React, Vue, atau aplikasi mobile — ceritanya beda.

Frontend yang mengurus tampilan form-nya sendiri. Dia tidak butuh Laravel mengembalikan halaman HTML berisi <input> dan <textarea>. Yang dia butuh hanya data: “Tolong simpan ini”, “Tolong ambil ini”, “Tolong hapus ini”. Tidak lebih.

Nah, itulah alasan dua method ini ada. Mereka menjawab dua kebutuhan yang berbeda.


🌐 Route::resource() — Untuk Web (HTML)

Analoginya

Bayangin Route::resource() seperti paket lengkap furniture kamar tidur dari toko.

Satu paket sudah mencakup segalanya: kasur, lemari, meja rias, nakas, cermin. Semua ada karena asumsinya adalah kamu butuh semuanya untuk kamar yang fungsional.

Begitu pula dengan Route::resource() — dia mendaftarkan semua route yang dibutuhkan aplikasi web berbasis HTML, termasuk route untuk menampilkan halaman form.

Route yang Dihasilkan

// routes/web.php
Route::resource('posts', PostController::class);

Satu baris di atas menghasilkan tujuh route sekaligus:

GET     /posts               → index   (daftar semua artikel)
GET     /posts/create        → create  (tampilkan form buat baru) 🖊️
POST    /posts               → store   (simpan artikel baru)
GET     /posts/{post}        → show    (detail satu artikel)
GET     /posts/{post}/edit   → edit    (tampilkan form edit) 🖊️
PUT/PATCH /posts/{post}      → update  (simpan hasil edit)
DELETE  /posts/{post}        → destroy (hapus artikel)

📌 Dua route yang ditandai 🖊️ — create dan edit — adalah yang membedakan resource() dari apiResource(). Keduanya mengembalikan halaman HTML berisi form, bukan data JSON.

Controller-nya

Saat pakai resource(), kamu biasanya generate controller dengan perintah:

php artisan make:controller PostController --resource

Hasilnya adalah controller dengan tujuh method kosong yang siap diisi:

class PostController extends Controller
{
    public function index() { /* daftar artikel */ }
    public function create() { /* tampilkan form create */ }
    public function store(Request $request) { /* simpan artikel baru */ }
    public function show(Post $post) { /* detail artikel */ }
    public function edit(Post $post) { /* tampilkan form edit */ }
    public function update(Request $request, Post $post) { /* simpan hasil edit */ }
    public function destroy(Post $post) { /* hapus artikel */ }
}

📡 Route::apiResource() — Untuk API (JSON)

Analoginya

Sekarang bayangin kamu pesan paket furniture yang sama, tapi untuk apartemen yang sudah punya desainer interior sendiri. Dia tidak butuh cermin dan meja rias — dia sudah urus itu sendiri.

apiResource() adalah paket yang sama tapi sudah dikurangi bagian yang tidak relevan. Hanya sisakan yang benar-benar dibutuhkan.

Route yang Dihasilkan

// routes/api.php
Route::apiResource('posts', PostController::class);

Satu baris ini menghasilkan lima route — tanpa create dan edit:

GET     /posts               → index   (kembalikan list JSON)
POST    /posts               → store   (terima data JSON, simpan)
GET     /posts/{post}        → show    (kembalikan satu data JSON)
PUT/PATCH /posts/{post}      → update  (terima data JSON, update)
DELETE  /posts/{post}        → destroy (hapus)

📌 create dan edit dihilangkan karena API tidak mengembalikan form HTML. Client (React, Vue, mobile app) yang mengurus tampilannya sendiri.

Controller-nya

php artisan make:controller PostController --api

Hasilnya controller dengan lima method — tanpa create dan edit:

class PostController extends Controller
{
    public function index() { /* return JSON list */ }
    public function store(Request $request) { /* simpan, return JSON */ }
    public function show(Post $post) { /* return JSON satu data */ }
    public function update(Request $request, Post $post) { /* update, return JSON */ }
    public function destroy(Post $post) { /* hapus */ }
}

🗺️ Gambaran Besar — Siapa Mengurus Apa

Route::resource()
┌─────────────────────────────────────┐
│ Laravel                             │
│  ├── Render form create (HTML)      │
│  ├── Render form edit (HTML)        │
│  ├── Terima submit form             │
│  └── Return response (redirect/view)│
└─────────────────────────────────────┘

Route::apiResource()
┌─────────────────────────────────────┐
│ Client (React / Vue / Mobile)       │
│  ├── Render form create             │
│  └── Render form edit               │
└─────────────────────────────────────┘
         ↕ HTTP Request (JSON)
┌─────────────────────────────────────┐
│ Laravel                             │
│  ├── Terima data JSON               │
│  └── Return response JSON           │
└─────────────────────────────────────┘

✂️ Bonus — only() dan except()

Tidak selalu kamu butuh semua route yang didaftarkan. Misalnya resource komentar — mungkin kamu hanya perlu store dan destroy, tanpa perlu halaman list atau detail.

Dua method ini membantu:

only() — daftarkan yang disebutkan saja:

Route::resource('comments', CommentController::class)
    ->only(['store', 'destroy']);

except() — daftarkan semua kecuali yang disebutkan:

Route::resource('posts', PostController::class)
    ->except(['create', 'edit']);

📌 only() dan except() bisa dipakai di keduanya — baik resource() maupun apiResource(). Berguna kalau ada route yang sengaja tidak ingin diekspos.


🧭 Jadi, Kapan Pakai Yang Mana?

Pakai Route::resource() ketika: Aplikasi kamu server-side rendered — artinya Laravel yang mengurus halaman HTML, termasuk form create dan edit. Cocok untuk aplikasi web tradisional yang pakai Blade.

Pakai Route::apiResource() ketika: Kamu bangun REST API — baik untuk SPA (React, Vue, Next.js), aplikasi mobile, atau layanan backend-to-backend. Client yang mengurus tampilan form, Laravel hanya terima dan kirim data JSON.


📊 Perbandingan Singkat

resource()apiResource()
Jumlah route75
Include create & edit
Response yang umumHTML / redirectJSON
Cocok untukAplikasi web (Blade)REST API
Perintah artisan--resource--api
File routeroutes/web.phproutes/api.php

💡 Ingat!

Perbedaan antara keduanya bukan soal mana yang lebih canggih atau lebih modern. Ini murni soal siapa yang bertanggung jawab atas tampilan form.

Kalau Laravel yang urus formnya — pakai resource(). Kalau frontend yang urus — pakai apiResource().

Dan kalau kamu lupa route mana saja yang sudah terdaftar, selalu bisa cek dengan:

php artisan route:list

Semua route akan terlihat rapi di sana, lengkap dengan nama, method, dan controller-nya.

Kalau kamu sudah pernah nulis controller Laravel, pasti pernah lihat kode kayak gini:

public function show(Post $post)
{
    return view('posts.show', compact('post'));
}

Dan kamu mungkin sempat bingung — “Di mana saya ambil $post-nya? Kok tiba-tiba ada?”

Itu bukan sulap. Itu Route Model Binding.

Di artikel ini, kita bahas dari dasarnya dulu — kenapa fitur ini ada, apa masalah yang dia selesaikan — baru masuk ke tiga jenisnya: implicit, explicit, dan custom.


🤔 Masalah yang Diselesaikan

Tanpa Route Model Binding, kode kamu biasanya keliatan begini:

// routes/web.php
Route::get('/posts/{id}', [PostController::class, 'show']);

// app/Http/Controllers/PostController.php
public function show($id)
{
    $post = Post::findOrFail($id);
    return view('posts.show', compact('post'));
}

Masalahnya bukan kodenya salah. Tapi kalau kamu punya 10 resource (Post, User, Comment, Product…), kamu bakal nulis findOrFail() di setiap controller, setiap method. Berulang-ulang. Sama persis.

Ini yang disebut boilerplate — kode yang monoton, tidak menambah nilai, tapi harus tetap ada.

Route Model Binding adalah cara Laravel bilang: “Tenang, bagian itu biar aku yang urus.”


🪄 Implicit Binding — “Yang Paling Sering Dipakai”

Analoginya

Bayangin kamu pesan makanan di restoran. Kamu cukup bilang: “Nomor meja 5.”

Pelayan langsung tahu harus ngambil pesanan siapa, tanpa kamu perlu jelasin “ambil dari database, cari id = 5, return objeknya.”

Itulah implicit binding. Laravel otomatis resolve model berdasarkan nama parameter di route dan type-hint di method controller — tanpa kamu nulis apapun secara eksplisit.

Syaratnya

Supaya implicit binding bekerja, ada dua syarat:

  1. Nama parameter di route harus sama dengan nama parameter di controller.
  2. Controller harus pakai type-hint model yang sesuai.

Kodenya

📁 routes/web.php

// Nama parameter: {post}
Route::get('/posts/{post}', [PostController::class, 'show']);

📁 app/Http/Controllers/PostController.php

// Type-hint: Post $post
// Nama parameter: $post — harus sama dengan {post} di route
public function show(Post $post)
{
    return view('posts.show', compact('post'));
}

📌 Laravel akan otomatis menjalankan Post::findOrFail($id) di balik layar. Kalau datanya tidak ada, langsung lempar 404 — tidak perlu kamu tangani sendiri.


🔑 Implicit Binding dengan Field Kustom

Secara default, implicit binding pakai primary key (id). Tapi kadang URL yang lebih baik itu pakai slug, bukan angka:

/posts/cara-belajar-laravel  ← lebih bagus dari /posts/42

Untuk kasus ini, ada dua cara.

Cara 1 — Inline di route (untuk 1–2 route spesifik):

// Tambahkan :slug setelah nama parameter
Route::get('/posts/{post:slug}', [PostController::class, 'show']);

Laravel akan menjalankan Post::where('slug', $value)->firstOrFail() — bukan pakai id.

Cara 2 — Override di model (kalau semua route pakai slug):

📁 app/Models/Post.php

class Post extends Model
{
    // Bilang ke Laravel: "Kalau resolve Post dari route, selalu pakai field ini"
    public function getRouteKeyName(): string
    {
        return 'slug';
    }
}

Lalu di route, tulis seperti biasa tanpa tambahan apapun:

Route::get('/posts/{post}', [PostController::class, 'show']);

📌 Cara 2 lebih rapi kalau semua route untuk model tersebut pakai field yang sama. Cara 1 lebih fleksibel kalau cuma satu-dua route yang butuh berbeda.


⚙️ Explicit Binding — “Kamu yang Kendalikan”

Analoginya

Masih di restoran yang sama. Tapi sekarang kamu bukan pelanggan biasa — kamu adalah manajer yang pasang aturan:

“Setiap kali ada yang sebut ‘Meja 5’, jangan cuma ambil pesanannya. Cek juga apakah mejanya sudah dibayar lunas, dan pastikan statusnya aktif.”

Explicit binding adalah tempat kamu mendaftarkan aturan itu secara global. Setiap kali parameter tertentu muncul di route manapun, logika yang kamu daftarkan akan selalu dijalankan.

Di mana mendaftarkannya

Di AppServiceProvider, tepatnya di method boot().

📁 app/Providers/AppServiceProvider.php

use Illuminate\Support\Facades\Route;
use App\Models\Post;

public function boot(): void
{
    // Setiap kali {post} muncul di route mana pun:
    Route::bind('post', function (string $value) {
        // Kamu bebas tulis query apapun di sini
        return Post::where('slug', $value)
                   ->where('is_published', true)
                   ->firstOrFail();
    });
}

📌 Perhatikan — di sini kamu bisa tambahkan filter apapun. Bukan hanya cari berdasarkan field tertentu, tapi juga cek kondisi tambahan seperti is_published = true. Kalau pakai implicit binding biasa, hal ini tidak bisa dilakukan.

Lalu di route dan controller, kamu tetap tulis seperti biasa:

// routes/web.php
Route::get('/posts/{post}', [PostController::class, 'show']);

// Controller
public function show(Post $post)
{
    return view('posts.show', compact('post'));
}

Tidak ada perubahan di route atau controller — yang berubah hanya logika resolving-nya, dan itu tersimpan rapi di satu tempat.


🗺️ Gambaran Besar — Tiga Cara dan Kapan Pakainya

URL: /posts/cara-belajar-laravel
         ↓
   {post} parameter
         ↓
   Bagaimana Laravel resolve?

┌─────────────────────────────────────────────────────────┐
│  1. Implicit (default)                                  │
│     WHERE id = ?                                        │
│                                                         │
│  2. Implicit + field kustom                             │
│     {post:slug} atau getRouteKeyName()                  │
│     WHERE slug = ?                                      │
│                                                         │
│  3. Explicit (Route::bind)                              │
│     Query sepenuhnya kamu tentukan                      │
│     WHERE slug = ? AND is_published = true              │
└─────────────────────────────────────────────────────────┘

🧭 Jadi, Kapan Pakai Yang Mana?

Pakai Implicit (default) ketika: Route pakai id dan tidak ada kondisi filter tambahan. Ini kasus paling umum.

Pakai Implicit + {post:slug} ketika: Hanya 1–2 route spesifik yang butuh field berbeda. Cepat, tidak perlu ubah apapun di tempat lain.

Pakai getRouteKeyName() ketika: Semua route untuk model tersebut selalu pakai field yang sama (misalnya semua /posts/{post} selalu resolve lewat slug). Lebih rapi dari menulis :slug di setiap route.

Pakai Explicit (Route::bind) ketika: Ada logika tambahan yang perlu selalu dijalankan — filter status, soft delete, kondisi khusus. Atau kalau nama parameter di URL berbeda dengan nama model.


📊 Perbandingan Singkat

Implicit{param:field}getRouteKeyName()Explicit (Route::bind)
KonfigurasiOtomatisDi routeDi modelDi Service Provider
ScopeGlobalPer routeGlobal per modelGlobal per parameter
Bisa tambah filter
Perlu ubah controller
Cocok untukKasus umum1–2 routeSatu model konsistenQuery kompleks

💡 Ingat!

Waktu pertama lihat Post $post di controller tanpa ada findOrFail(), wajar kalau rasanya seperti ada yang hilang.

Tapi justru di situlah intinya — Route Model Binding menghilangkan kode yang tidak perlu kamu lihat, supaya yang tersisa di controller hanya logika yang benar-benar penting.

Satu hal yang perlu diingat: kalau data tidak ditemukan, Laravel otomatis lempar response 404. Ini behavior default yang bisa kamu andalkan — tidak perlu if (!$post) abort(404) lagi.

Dan kalau suatu saat butuh logika yang lebih dari sekadar cari berdasarkan field, Route::bind() ada di sana. Bukan untuk dipakai setiap saat, tapi sangat berharga ketika dibutuhkan.