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.
La séquence correcte est :
- Terminez la transaction et capturez tous les identifiants ou résultats dont vous avez besoin.
- Ce n'est qu'ensuite que vous devez déclencher les effets de bord externes.
Par exemple, mettez l'e-mail de confirmation en file d'attente après le commit, et non à l'intérieur de celui-ci :
$order = DB::transaction(function () {
// database work only
return Order::create([/* ... */]);
});
// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);
Il existe une deuxième raison de maintenir les appels externes en dehors de la transaction : le temps. Une transaction maintient des verrous et occupe une connexion à la base de données. Attendre trois secondes une réponse de l'API Stripe alors que vous êtes à l'intérieur d'une transaction représente trois secondes de temps de verrouillage inutile. Gardez la transaction courte et rapide.
Pourquoi ce bug est difficile à détecter
Sur une machine de développement avec un utilisateur unique et une base de données locale, les insertions liées réussissent presque toujours. Le réseau est stable, le disque ne se remplit jamais et il n'y a pas de charge concurrente. Le code semble correct car il fonctionne généralement.
La production est différente. Deux clients passent commande à la milliseconde exacte près. Un worker de file d'attente redémarre en plein milieu d'une tâche lors d'un déploiement. Un fournisseur de paiement subit un timeout de trente secondes. Sans transactions, ces événements créent des enregistrements orphelins et des totaux incohérents qu'il est pénible de tracer. Le pire, c'est que les tests de fonctionnalités standards les détectent rarement, car le mode de défaillance dépend du timing et de l'infrastructure, et non de simples erreurs de logique.
La solution n'est pas exotique. Elle est mécanique. Dès que vous voyez deux écritures ou plus liées entre elles, enveloppez-les. Avec le temps, cela devrait devenir aussi automatique que la validation d'une requête.
Faites-en un réflexe
DB::transaction() n'ajoute pas de complexité. Cela élimine la complexité cachée consistant à essayer de nettoyer des données partielles après coup. Si un groupe d'opérations de base de données est lié, traitez-les ainsi dès le départ. Votre application restera fiable sous la charge, vos journaux d'erreurs resteront propres et votre base de données ne deviendra pas un cimetière d'enregistrements inachevés.
