Transfer uang dari dompet Budi ke dompet Siti membutuhkan dua UPDATE: saldo Budi berkurang dan saldo Siti bertambah. Bayangkan bila query pertama sukses lalu aplikasi mati listrik sebelum query kedua jalan — uang lenyap begitu saja. Database transaction menyelesaikan masalah ini dengan mengelompokkan beberapa query menjadi satu unit kerja atomik: semuanya berhasil, atau tidak ada satu pun yang tersimpan. Chapter penutup section ini membahas DB::transaction, kontrol manual beginTransaction/commit/rollBack, transaksi bersarang, dan hook afterCommit — semuanya diuji langsung pada SQLite.

Konsep Transaksi Database #

Transaksi memakai pola tiga langkah yang sama di semua database relasional: BEGIN membuka transaksi, COMMIT mengunci semua perubahan secara permanen, dan ROLLBACK membuangnya seolah tidak pernah terjadi. Selama transaksi terbuka, perubahan hanya terlihat oleh koneksi itu sendiri — koneksi lain tidak melihat saldo yang setengah berpindah. Inilah huruf A pada istilah ACID (atomicity), dan Laravel menyediakan dua gaya API di atasnya: closure otomatis dan kontrol manual. Keduanya dirinci di dokumentasi database .

DB::transaction() #

Cara paling ringkas adalah mengirim closure ke DB::transaction(). Siapkan dua tabel uji terlebih dahulu — wallets dengan kolom saldo serta transfers sebagai catatan:

php
Schema::create('wallets', function (Blueprint $table) {
    $table->id();
    $table->string('kode')->unique();
    $table->string('pemilik');
    $table->unsignedInteger('saldo');
});

Schema::create('transfers', function (Blueprint $table) {
    $table->id();
    $table->string('dari');
    $table->string('ke');
    $table->unsignedInteger('jumlah');
});

DB::table('wallets')->insert([
    ['kode' => 'BUDI', 'pemilik' => 'Budi', 'saldo' => 100000],
    ['kode' => 'SITI', 'pemilik' => 'Siti', 'saldo' => 50000],
]);

function saldo(string $kode): int
{
    return (int) DB::table('wallets')->where('kode', $kode)->value('saldo');
}

Transfer pertama sebesar 30000 kita jalankan dalam satu closure, lalu transfer kedua 500000 sengaja gagal karena saldo Budi tidak cukup:

php
DB::transaction(function () {
    DB::table('wallets')->where('kode', 'BUDI')->decrement('saldo', 30000);
    DB::table('wallets')->where('kode', 'SITI')->increment('saldo', 30000);
});
echo 'Setelah transfer sukses 30000:', "\n";
echo '  Budi: ', saldo('BUDI'), ' | Siti: ', saldo('SITI'), "\n";

echo 'Mencoba transfer 500000 dari Budi (saldo ', saldo('BUDI'), ")...\n";
try {
    DB::transaction(function () {
        DB::table('wallets')->where('kode', 'BUDI')->decrement('saldo', 500000);
        if (saldo('BUDI') < 0) {
            throw new RuntimeException('Saldo tidak cukup');
        }
        DB::table('wallets')->where('kode', 'SITI')->increment('saldo', 500000);
    });
} catch (RuntimeException $e) {
    echo '  Exception tertangkap: ', $e->getMessage(), "\n";
}
echo 'Setelah transfer gagal:', "\n";
echo '  Budi: ', saldo('BUDI'), ' | Siti: ', saldo('SITI'), "\n";

Output yang Dihasilkan:

text
Setelah transfer sukses 30000:
  Budi: 70000 | Siti: 80000
Mencoba transfer 500000 dari Budi (saldo 70000)...
  Exception tertangkap: Saldo tidak cukup
Setelah transfer gagal:
  Budi: 70000 | Siti: 80000

Transfer pertama tersimpan normal. Pada transfer kedua, decrement di baris pertama closure sempat membuat saldo Budi menjadi -430000, tetapi begitu exception dilempar, Laravel otomatis menjalankan ROLLBACK dan menutup transaksi — saldo kedua pihak kembali ke keadaan sebelum transaksi dibuka. Setelah rollback selesai, exception diteruskan keluar sehingga kode catch di caller kita bisa menanganinya. Method ini juga menerima parameter kedua berisi jumlah percobaan: DB::transaction(fn () => /* ... */, 3) akan mengulang transaksi hingga tiga kali bila terjadi deadlock.

Kontrol Manual: beginTransaction, commit, rollBack #

Gaya kedua memberi kendali penuh lewat tiga method eksplisit — pola standarnya try/catch. Sebelum melanjutkan, tabel wallets dan transfers kita set ulang ke kondisi awal (Budi 100000, Siti 50000) supaya angka-angka berikutnya mudah diikuti:

php
DB::beginTransaction();
try {
    DB::table('wallets')->where('kode', 'BUDI')->decrement('saldo', 20000);
    DB::table('wallets')->where('kode', 'SITI')->increment('saldo', 20000);
    DB::table('transfers')->insert(['dari' => 'BUDI', 'ke' => 'SITI', 'jumlah' => 20000]);
    DB::commit();
    echo 'Commit manual: transfer 20000 tercatat', "\n";
} catch (Throwable $e) {
    DB::rollBack();
    throw $e;
}

Untuk membuktikan rollback memang membuang perubahan, kita jalankan transaksi kedua yang sengaja tidak di-commit — saldo Budi dikurangi 99999, kita lihat isinya, lalu batalkan:

php
DB::beginTransaction();
DB::table('wallets')->where('kode', 'BUDI')->decrement('saldo', 99999);
echo 'Di dalam transaksi (belum commit): saldo Budi = ',
    DB::table('wallets')->where('kode', 'BUDI')->value('saldo'), "\n";
DB::rollBack();
echo 'Setelah rollBack: saldo Budi = ',
    DB::table('wallets')->where('kode', 'BUDI')->value('saldo'), "\n";

Output yang Dihasilkan:

text
Commit manual: transfer 20000 tercatat
Di dalam transaksi (belum commit): saldo Budi = -19999
Setelah rollBack: saldo Budi = 80000

Di dalam transaksi terbuka, saldo terbaca sebagai -19999 — perubahan itu nyata pada koneksi kita. Sesaat setelah rollBack(), saldo kembali menjadi 80000, nilai hasil commit transaksi pertama. Pada praktiknya DB::transaction() hampir selalu lebih aman karena lupa commit atau rollBack tidak mungkin terjadi; gaya manual dipakai bila commit harus terjadi di titik yang tidak bisa dibungkus satu closure.

Transaksi Bersarang dan Savepoint #

Bila DB::transaction() dipanggil di dalam transaksi lain, Laravel tidak membuka transaksi SQL kedua, melainkan membuat savepoint — penanda yang bisa dikembalikan tanpa membatalkan seluruh transaksi. SQLite mendukung savepoint, jadi kita bisa mengujinya langsung:

php
DB::transaction(function () {
    DB::table('transfers')->insert(['dari' => 'BUDI', 'ke' => 'SITI', 'jumlah' => 10000]);
    DB::table('wallets')->where('kode', 'BUDI')->decrement('saldo', 10000);
    DB::table('wallets')->where('kode', 'SITI')->increment('saldo', 10000);

    DB::afterCommit(fn () => print("afterCommit: notifikasi transfer dikirim\n"));

    try {
        DB::transaction(function () {
            // kolom jumlah sengaja dikosongkan — NOT NULL akan menolak
            DB::table('transfers')->insert(['dari' => 'BUDI', 'ke' => 'SITI']);
        });
    } catch (Illuminate\Database\QueryException $e) {
        echo 'Transaksi dalam gagal: ', $e->errorInfo[2], "\n";
    }

    echo 'Transaksi luar lanjut setelah savepoint dibatalkan', "\n";
});

echo 'Jumlah baris transfers: ', DB::table('transfers')->count(), "\n";
echo 'Saldo akhir — Budi: ', saldo('BUDI'), ' | Siti: ', saldo('SITI'), "\n";

Output yang Dihasilkan:

text
Transaksi dalam gagal: NOT NULL constraint failed: transfers.jumlah
Transaksi luar lanjut setelah savepoint dibatalkan
afterCommit: notifikasi transfer dikirim
Jumlah baris transfers: 2
Saldo akhir — Budi: 70000 | Siti: 80000

Insert di transaksi dalam gagal karena kolom jumlah kosong, dan savepoint dibatalkan. Namun transaksi luar tetap sehat: tiga query sebelumnya tersimpan, tabel transfers berisi dua baris, dan perpindahan 10000 berjalan. Tanpa savepoint, kegagalan kecil semacam ini akan meruntuhkan seluruh operasi induk.

Perhatikan juga letak DB::afterCommit() pada contoh di atas: closure-nya baru berjalan setelah transaksi terluar berhasil di-commit. Callback ini adalah tempat yang tepat mengirim notifikasi atau memicu event — tugas yang hanya layak terjadi bila data benar-benar tersimpan. Kebalikannya sudah kita uji juga: bila transaksi berakhir rollBack, callback afterCommit tidak pernah dijalankan.

Kapan Tidak Memakai Transaksi #

Transaksi bukan alat serbaguna. Semakin lama transaksi terbuka, semakin lama kunci (lock) ditahan, dan query lain bisa menunggu di belakangnya — di database bersama seperti MySQL atau PostgreSQL, transaksi panjang berpotensi menghambat seluruh aplikasi. Jangan menaruh pemanggilan HTTP ke API eksternal, pengiriman email, atau pekerjaan berat di dalam closure DB::transaction(), karena lama eksekusinya tidak bisa dikendalikan database. Untuk pekerjaan semacam itu, kerjakan di background job — topik Section Queues dan Background Jobs — dan bungkus hanya bagian penulisan database dengan transaksi.

Dengan transaksi, perjalanan kita di section Database dan Migrations tuntas: skema dibangun lewat migration dengan tipe kolom, index, dan foreign key yang tepat, lalu diisi lewat seeder dan factory, kini dijaga konsistensinya lewat transaksi. Perlengkapan ini kita bawa ke Section Eloquent ORM , tempat interaksi database berubah dari query mentah menjadi objek PHP yang hidup.