All of it, or none of it
DB is a facade — a static shortcut to your default database connection. When you write DB::transaction() or DB::statement(), you are not talking to a special SQL language. Laravel forwards those calls to the connection and runs them there.
That is the whole trick. Two methods, two jobs: one decides when work becomes permanent, the other runs the SQL inside that window.
Terminology
A few words you will see throughout. Worth pinning down before the code.
- Facade — a static PHP class that proxies through to a real object in Laravel’s container.
DBis the facade. The connection is the thing that actually talks to Postgres. - Connection — the live database session (Postgres, SQLite, MySQL).
DB::transaction()runs on whichever connection is currently default. - Closure — an anonymous function you pass as a value.
function () { ... }is a closure.DB::transaction()takes one, runs it inside the transaction, and returns whatever that closure returns.use ($fromId, $toId, $amount)pulls outer variables into the closure’s scope so it can see them. - Transaction — a unit of work the database treats as one. It either fully lands or fully disappears. There is no in-between.
- BEGIN — the SQL that opens a transaction. Laravel sends this for you when the closure starts.
- COMMIT — make every change in the transaction permanent. Happens if the closure finishes without throwing.
- ROLLBACK — undo every change in the transaction. Happens if anything throws.
- Savepoint — a named checkpoint inside an already-open transaction. Nested
DB::transaction()calls use these instead of a secondBEGIN. The real commit is still the outermost one. - Row lock — a hold on one row so nobody else can change it until you are done.
lockForUpdate()asks for one. It only lasts until the transaction ends. - Statement — a piece of SQL you execute.
DB::statement()runs it and gives you true or false. It does not return rows. - Binding / prepared statement — the
?placeholders plus the values array. The database receives the SQL and the values separately, which is what stops SQL injection. - Upsert — insert a row, or update/skip if it already exists.
ON CONFLICT DO NOTHINGis the skip flavour.
DB::transaction()
A transaction is a promise from the database: these statements succeed together, or none of them happen. The classic example is moving money. Debit one account, credit the other — both writes, or neither.
<?php
use App\Models\Account;
use Illuminate\Support\Facades\DB;
return DB::transaction(function () use ($fromId, $toId, $amount) {
$from = Account::whereKey($fromId)->lockForUpdate()->firstOrFail();
$to = Account::whereKey($toId)->lockForUpdate()->firstOrFail();
$from->balance -= $amount;
$to->balance += $amount;
$from->save();
$to->save();
});
Laravel runs BEGIN, then your closure, then COMMIT. If anything throws, it ROLLBACKs and rethrows. The closure’s return value is what transaction() returns — that is why you can return DB::transaction(...) from a method.
Useful for grouping writes that must succeed together:
- debiting one account and crediting another
- creating an order and decrementing stock
- deleting a parent row and its children
Also useful whenever you need a row lock (lockForUpdate()) to last until you are finished. The lock dies with the transaction. Call it outside one and Postgres takes the lock for that single SELECT, then drops it. The next line is already racing.
If you call DB::transaction() while already inside one, Laravel uses a savepoint, not a second BEGIN. The real commit happens when the outermost transaction finishes.
The longhand is beginTransaction() / commit() / rollBack() in a try/catch. The closure form cannot forget the rollback. Prefer it.
DB::statement()
This is the “run this SQL, I only care that it ran” method. Laravel gives you the bare minimum:
<?php
use Illuminate\Support\Facades\DB;
DB::statement(
'INSERT INTO wallets (user_id, balance) VALUES (?, 0) ON CONFLICT (user_id) DO NOTHING',
[$userId],
);
It prepares the SQL, binds each ? from the second argument, executes, and returns true or false. No result rows. No row count.
Useful for SQL the query builder does not express cleanly:
ON CONFLICT DO NOTHINGCREATE EXTENSION- one-off
SETcommands - raw upserts where insert-or-skip is still success
The ? plus [$userId] is a prepared statement. That is what makes it safe. Do not interpolate values into the SQL string.
ON CONFLICT DO NOTHING is a good example of why you want a boolean back, not a row count. Insert or skip — both are success. You just need the wallet row to exist before you lock it.
Now put both together. Ensure the wallet exists, lock it, then take the payment. If save() throws, the insert and the debit both roll back.
<?php
namespace App\Services;
use App\Models\Wallet;
use Illuminate\Support\Facades\DB;
class WalletPayment
{
public function debit(int $userId, int $amount): Wallet
{
return DB::transaction(function () use ($userId, $amount) {
DB::statement(
'INSERT INTO wallets (user_id, balance) VALUES (?, 0) ON CONFLICT (user_id) DO NOTHING',
[$userId],
);
$wallet = Wallet::where('user_id', $userId)->lockForUpdate()->firstOrFail();
$wallet->balance -= $amount;
$wallet->save();
return $wallet;
});
}
}
DB::transaction() is the window. DB::statement() is the raw upsert inside it. lockForUpdate() only lasts until that transaction commits — which is the point.
Summary
DB is a facade over the default connection. transaction() is the window in which work becomes permanent — all of it, or none of it. statement() is how you run raw SQL inside that window when the query builder is the wrong tool. Once those two click, a lot of “clever” database code is just using them on purpose. Happy Coding!