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:
- Kolom yang sering masuk
WHERE—status,email,username - Kolom yang sering di-
ORDER BY—created_at - Foreign key —
user_id,order_id(Laravel tidak otomatis index ini sejak versi terbaru, kecualiforeignId())
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:
- Kolom yang nilainya sering berubah —
password, token,updated_at - Kolom dengan sedikit nilai unik — kolom boolean
is_active(hanya 0 atau 1, tidak efisien) - Kolom teks panjang —
description,body,notes
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
| Teknik | Masalah 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.