Anda telah menyimpan pesanan, tetapi item pesanan tidak pernah masuk ke database. Atau mungkin penghitung stok berkurang, gateway pembayaran mengalami timeout, dan sekarang pelanggan memiliki tagihan di kartu mereka tetapi tidak ada catatan pesanan. Skenario ini terdengar seperti kasus ekstrem sampai akhirnya hal itu benar-benar terjadi. Pada aplikasi yang sibuk, hal ini menjadi sakit kepala harian.
Penyebab utamanya hampir selalu sama: serangkaian penulisan database yang diperlakukan sebagai peristiwa terpisah dan terisolasi. Ketika satu gagal, yang lain tetap tertinggal. Solusinya adalah transaksi database, dan di Laravel, alatnya adalah DB::transaction().
Apa yang Sebenarnya Dijanjikan oleh Sebuah Transaksi
Sebuah transaksi database mengikat beberapa operasi menjadi satu kesatuan kerja. Mesin database menjamin bahwa semua yang ada di dalamnya akan di-commit secara permanen atau di-rollback sepenuhnya. Tidak ada jalan tengah.
Bayangkan sebuah transfer bank. Sistem harus mendebit satu akun dan mengkredit akun lainnya. Jika pengkreditan gagal setelah pendebitan, uang tersebut tidak hilang begitu saja ke ruang hampa. Bank akan membatalkan pendebitan tersebut. Pembatalan itulah yang disebut rollback. Jika kedua langkah berhasil, transfer di-commit, yang berarti saldo baru telah disimpan secara permanen.
Perilaku "semua-atau-tidak-sama-sekali" inilah yang menjaga konsistensi data Anda. Tanpa itu, kegagalan parsial akan menyebarkan catatan yang setengah tertulis di seluruh tabel Anda, dan catatan tersebut akan membusuk di database Anda karena tidak ada proses otomatis yang tahu cara membersihkannya dengan aman.
Bagaimana Laravel Melakukan Pengaturannya
Dalam SQL biasa, Anda akan menulis BEGIN, COMMIT, dan ROLLBACK sendiri, sambil mengingat untuk menangkap setiap kemungkinan error agar Anda tidak pernah membiarkan transaksi menggantung. Laravel membungkus boilerplate tersebut ke dalam satu metode tunggal.
Anda mengirimkan sebuah closure ke DB::transaction(). Laravel memulai transaksi, menjalankan kode Anda, dan jika closure selesai tanpa melempar exception, ia akan melakukan commit secara otomatis. Jika terjadi kegagalan, Laravel menangkap exception tersebut, melakukan rollback pada semuanya, dan melempar kembali error tersebut sehingga logging dan penanganan error Anda tetap berfungsi seperti yang diharapkan.
use Illuminate\Support\Facades\DB;
DB::transaction(function () {
$order = Order::create([/* ... */]);
foreach ($cart->items as $item) {
OrderItem::create([
'order_id' => $order->id,
'product_id' => $item->product_id,
'quantity' => $item->quantity,
]);
Product::find($item->product_id)
->decrement('stock', $item->quantity);
}
Payment::create([
'order_id' => $order->id,
'amount' => $cart->total,
'status' => 'completed',
]);
});
Jika penyisipan pembayaran gagal karena foreign key hilang atau koneksi database terputus, pesanan, item pesanan, dan perubahan stok semuanya akan dibatalkan. Anda tidak akan dibiarkan dengan inventaris yang hilang tanpa alasan, dan Anda tidak akan dibiarkan dengan pesanan yang menuntut pengiriman padahal belum dibayar.
Di Mana Ini Menyelamatkan Anda
Beberapa alur kerja sangat bergantung pada keamanan transaksional. Contoh checkout adalah yang paling jelas, tetapi pola ini muncul di mana-mana.
Registrasi pengguna. Membuat baris pengguna, lalu baris profil, lalu pengaturan default. Jika penyisipan profil gagal karena kasus ekstrem pada validasi, pengguna tanpa profil akan menjadi akun hantu. Halaman apa pun yang mengasumsikan setiap pengguna memiliki profil akan crash atau menampilkan UI yang rusak.
Impor massal. Unggahan CSV yang menyisipkan lima puluh catatan tidak boleh menyisakan dua puluh lima catatan hanya karena baris kedua puluh enam memiliki format tanggal yang salah. Membungkus batch tersebut dalam sebuah transaksi memungkinkan seluruh proses impor gagal sebagai satu kesatuan yang bersih. Admin dapat memperbaiki file dan mencoba lagi, daripada menghabiskan waktu berjam-jam mencari baris mana yang bocor sebagian.
Inventaris dan akuntansi. Kapan pun satu tabel melacak sumber daya fisik dan tabel lain melacak uang atau kredit, keduanya harus bergerak bersama. Memisahkan keduanya akan mengundang audit yang tidak sinkron dan laporan yang tidak akurat.
Kapan Anda Perlu Mengaturnya Secara Manual
Pendekatan closure mencakup sebagian besar kasus, tetapi terkadang Anda membutuhkan kontrol lebih. Logika kondisional yang kompleks di dalam service class, atau kebutuhan untuk memutuskan pada saat runtime apakah akan melakukan commit, dapat membuat penggunaan satu closure terasa canggung. Pada saat-saat seperti itu, Anda dapat mengelola transaksi sendiri:
DB::beginTransaction();
try {
// Run your operations
$order = Order::create([/* ... */]);
// ... more work ...
if ($someBusinessRulePasses) {
DB::commit();
} else {
DB::rollBack();
}
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
Perhatikan urutan di dalam blok catch: lakukan rollback terlebih dahulu, baru kemudian lempar (throw) error. Jika Anda melempar error sebelum melakukan rollback, transaksi akan tetap terbuka pada koneksi tersebut. Hal itu dapat mengunci baris (lock rows), menyebabkan deadlock pada query lain, atau menghabiskan connection pool Anda. Transaksi manual sangat kuat, tetapi mereka memberikan beban pembersihan kepada Anda.
Jauhkan Efek Samping dari Transaksi
Ini adalah aturan yang sering menyulitkan tim di lingkungan produksi. Sebuah transaksi hanya dapat membatalkan pekerjaan database. Ia tidak dapat membatalkan pengiriman email, menghapus file dari cloud storage, atau mengembalikan dana (refund) melalui API pembayaran.
Jika Anda menempatkan panggilan Mail::send() di dalam closure transaksi, dan database melakukan rollback dua baris kemudian, email tersebut tetap akan masuk ke kotak masuk pelanggan. Penerima kini memiliki invoice untuk pesanan yang tidak ada di sistem Anda. Hal yang sama berlaku untuk notifikasi Slack, unggahan file ke S3, atau pengiriman webhook.
The correct sequence is:
- Complete the transaction and capture any IDs or results you need.
- Only then trigger external side effects.
For example, queue the confirmation email after the commit, not inside it:
$order = DB::transaction(function () {
// database work only
return Order::create([/* ... */]);
});
// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);
There is a second reason to keep external calls outside the transaction: time. A transaction holds locks and keeps a database connection busy. Waiting three seconds for a Stripe API response while inside a transaction is three seconds of unnecessary lock time. Keep the transaction tight and fast.
Why This Bug Hides
On a development machine with a single user and a local database, related inserts almost always succeed. The network is stable, the disk never fills, and there is no competing load. The code looks correct because it usually works.
Production is different. Two customers submit orders at the exact same millisecond. A queue worker restarts mid-job during a deployment. A payment provider times out for thirty seconds. Without transactions, these events create orphan records and mismatched totals that are painful to trace. The worst part is that standard feature tests rarely catch them, because the failure mode is timing-dependent and tied to infrastructure, not simple logic errors.
The fix is not exotic. It is mechanical. You see two or more related writes, and you wrap them. Over time, it should become as automatic as validating a request.
Make It a Reflex
DB::transaction() does not add complexity. It removes the hidden complexity of trying to clean up partial data after the fact. If a group of database operations belongs together, treat them that way from the start. Your application will stay honest under load, your error logs will stay clean, and your database will not become a graveyard of half-finished records.
