You saved the order, but the order items never made it to the database. Or maybe the stock counter dropped, the payment gateway threw a timeout, and now the customer has a charge on their card but no order record. These scenarios sound like edge cases until they are not. On a busy application, they become daily headaches.
The root cause is almost always the same: a sequence of database writes that were treated as separate, isolated events. When one fails, the others stay behind. The fix is a database transaction, and in Laravel, the tool is DB::transaction().
What a Transaction Actually Promises
A database transaction binds multiple operations into a single unit of work. The database engine guarantees that everything inside either commits permanently or rolls back completely. There is no middle ground.
Think of a bank transfer. The system must debit one account and credit another. If the credit fails after the debit, the money does not simply vanish into the void. The bank reverses the debit. That reversal is the rollback. If both steps succeed, the transfer is committed, meaning the new balances are durably saved.
This all-or-nothing behavior is what keeps your data consistent. Without it, partial failures scatter half-written records across your tables, and those records rot in your database because no automated process knows how to clean them up safely.
How Laravel Does the Wiring
In plain SQL, you would write BEGIN, COMMIT, and ROLLBACK yourself, remembering to catch every possible error so you never leave a transaction hanging. Laravel wraps that boilerplate into a single method.
You pass a closure to DB::transaction(). Laravel starts the transaction, runs your code, and if the closure finishes without throwing an exception, it commits automatically. If anything fails, Laravel catches the exception, rolls everything back, and rethrows the error so your logging and error handling still work as expected.
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',
]);
});
If the payment insert fails because a foreign key is missing or the database connection drops, the order, the order items, and the stock changes are all undone. You are not left with inventory that vanished for no reason, and you are not left with an order demanding shipment that was never paid for.
Where This Saves You
Some workflows absolutely depend on transactional safety. The checkout example is the most obvious, but the pattern shows up everywhere.
User registration. Creating a user row, then a profile row, then default settings. If the profile insert fails because of a validation edge case, a user without a profile becomes a ghost account. Any page that assumes every user has a profile will crash or show broken UI.
Bulk imports. A CSV upload that inserts fifty records should not leave twenty-five behind because row twenty-six had a malformed date. Wrapping the batch in a transaction lets the entire import fail as one clean unit. The admin fixes the file and tries again, rather than spending hours hunting down which rows partially leaked through.
Inventory and accounting. Any time one table tracks a physical resource and another tracks money or credits, the two must move together. Splitting them invites audits that do not reconcile and reports that lie.
When You Need to Drive Manual
The closure approach covers most cases, but sometimes you need more control. Complex conditional logic inside a service class, or the need to decide at runtime whether to commit, can make a single closure feel awkward. In those moments, you can manage the transaction yourself:
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;
}
Notice the order inside the catch block: roll back first, then throw. If you throw before rolling back, the transaction stays open on the connection. That can lock rows, deadlock other queries, or exhaust your connection pool. Manual transactions are powerful, but they put the cleanup burden on you.
Keep Side Effects Out of the Transaction
This is the rule that bites teams in production. A transaction can only undo database work. It cannot unsend an email, delete a file from cloud storage, or refund a charge through a payment API.
If you place an Mail::send() call inside the transaction closure, and the database rolls back two lines later, that email still hits the customer’s inbox. The recipient now has an invoice for an order that does not exist in your system. The same applies to Slack notifications, file uploads to S3, or webhook dispatches.
A sequência correta é:
- Conclua a transação e capture quaisquer IDs ou resultados de que precise.
- Somente então acione efeitos colaterais externos.
Por exemplo, coloque o e-mail de confirmação na fila após o commit, não dentro dele:
$order = DB::transaction(function () {
// database work only
return Order::create([/* ... */]);
});
// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);
Existe uma segunda razão para manter chamadas externas fora da transação: o tempo. Uma transação mantém bloqueios e mantém uma conexão de banco de dados ocupada. Esperar três segundos por uma resposta da API do Stripe enquanto estiver dentro de uma transação significa três segundos de tempo de bloqueio desnecessário. Mantenha a transação curta e rápida.
Por que este bug se esconde
Em uma máquina de desenvolvimento com um único usuário e um banco de dados local, inserções relacionadas quase sempre funcionam. A rede é estável, o disco nunca enche e não há carga concorrente. O código parece correto porque geralmente funciona.
A produção é diferente. Dois clientes enviam pedidos no exato mesmo milissegundo. Um worker de fila reinicia no meio de um job durante um deploy. Um provedor de pagamento sofre um timeout de trinta segundos. Sem transações, esses eventos criam registros órfãos e totais divergentes que são difíceis de rastrear. A pior parte é que testes de funcionalidade padrão raramente os detectam, porque o modo de falha depende do timing e está ligado à infraestrutura, não a erros simples de lógica.
A correção não é exótica. É mecânica. Você vê duas ou mais escritas relacionadas e as envolve em uma transação. Com o tempo, isso deve se tornar tão automático quanto validar uma requisição.
Torne isso um reflexo
DB::transaction() não adiciona complexidade. Ele remove a complexidade oculta de tentar limpar dados parciais posteriormente. Se um grupo de operações de banco de dados pertence um ao outro, trate-os dessa forma desde o início. Sua aplicação permanecerá íntegra sob carga, seus logs de erro permanecerão limpos e seu banco de dados não se tornará um cemitério de registros inacabados.
