Je hebt de bestelling opgeslagen, maar de bestelartikelen zijn nooit in de database terechtgekomen. Of misschien is de voorraadteller verlaagd, heeft de payment gateway een timeout gegeven, en heeft de klant nu een afschrijving op zijn kaart maar geen bestelrecord. Deze scenario's klinken als edge cases totdat dat niet meer zo is. In een drukke applicatie worden ze dagelijkse hoofdpijn.

De hoofdoorzaak is bijna altijd hetzelfde: een reeks database-schrijfacties die als afzonderlijke, geïsoleerde gebeurtenissen werden behandeld. Wanneer er één mislukt, blijven de anderen achter. De oplossing is een database-transactie, en in Laravel is het hulpmiddel DB::transaction().

Wat een transactie eigenlijk belooft

Een database-transactie bindt meerdere operaties samen tot één enkele eenheid van werk. De database-engine garandeert dat alles binnen de transactie ofwel permanent wordt vastgelegd (commit), ofwel volledig wordt teruggedraaid (rollback). Er is geen tussenweg.

Denk aan een bankoverschrijving. Het systeem moet een rekening debiteren en een andere crediteren. Als de creditering mislukt na de debitering, verdwijnt het geld niet zomaar in het niets. De bank draait de debitering terug. Die terugdraaiing is de rollback. Als beide stappen slagen, wordt de overschrijving vastgelegd (committed), wat betekent dat de nieuwe saldi duurzaam zijn opgeslagen.

Dit alles-of-niets-gedrag is wat je gegevens consistent houdt. Zonder dit zorgen gedeeltelijke mislukkingen voor half geschreven records verspreid over je tabellen, en die records rotten in je database omdat geen enkel geautomatiseerd proces weet hoe ze veilig op te ruimen.

Hoe Laravel de verbinding legt

In gewone SQL zou je zelf BEGIN, COMMIT en ROLLBACK schrijven, waarbij je moet onthouden om elke mogelijke fout op te vangen zodat je nooit een transactie open laat staan. Laravel verpakt die boilerplate in een enkele methode.

Je geeft een closure door aan DB::transaction(). Laravel start de transactie, voert je code uit, en als de closure eindigt zonder een exception te gooien, wordt deze automatisch vastgelegd (commit). Als er iets misgaat, vangt Laravel de exception op, draait alles terug (rollback) en werpt de fout opnieuw (rethrow) zodat je logging en foutafhandeling nog steeds werken zoals verwacht.

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',
    ]);
});

Als de betalingsinvoeging mislukt omdat een foreign key ontbreekt of de databaseverbinding wegvalt, worden de bestelling, de bestelartikelen en de voorraadwijzigingen allemaal ongedaan gemaakt. Je blijft niet achter met voorraad die zonder reden is verdwenen, en je blijft niet achter met een bestelling die verzending vereist maar nooit is betaald.

Waar dit je tijd en moeite bespaart

Sommige workflows zijn absoluut afhankelijk van transactionele veiligheid. Het voorbeeld van de checkout is het meest voor de hand liggend, maar het patroon komt overal terug.

Gebruikersregistratie. Het aanmaken van een gebruikersrij, dan een profielrij, en vervolgens de standaardinstellingen. Als de profielinvoeging mislukt vanwege een edge case in de validatie, wordt een gebruiker zonder profiel een 'ghost account'. Elke pagina die ervan uitgaat dat elke gebruiker een profiel heeft, zal crashen of een kapotte UI tonen.

Bulk-imports. Een CSV-upload die vijftig records invoegt, zou er niet voor moeten zorgen dat er slechts vijfentwintig achterblijven omdat de zesentwintigste rij een ongeldige datum had. Door de batch in een transactie te plaatsen, kan de volledige import als één schone eenheid mislukken. De beheerder herstelt het bestand en probeert het opnieuw, in plaats van urenlang te moeten zoeken naar welke rijen er gedeeltelijk doorheen zijn gesijpeld.

Voorraad en boekhouding. Telkens wanneer de ene tabel een fysieke bron bijhoudt en een andere tabel geld of kredieten, moeten die twee samen bewegen. Het splitsen ervan nodigt uit tot audits die niet kloppen en rapportages die liegen.

Wanneer je handmatig moet sturen

De closure-aanpak dekt de meeste gevallen, maar soms heb je meer controle nodig. Complexe conditionele logica binnen een serviceklasse, of de noodzaak om tijdens runtime te beslissen of je moet committen, kan een enkele closure onhandig maken. Op die momenten kun je de transactie zelf beheren:

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;
}

Let op de volgorde binnen het catch-blok: eerst rollback, dan throw. Als je gooit (throw) voordat je terugdraait (rollback), blijft de transactie openstaan op de verbinding. Dat kan rijen vergrendelen (lock), andere queries in een deadlock brengen of je connection pool uitputten. Handmatige transacties zijn krachtig, maar ze leggen de verantwoordelijkheid voor het opruimen bij jou.

Houd side effects buiten de transactie

Dit is de regel die teams in productie hard raakt. Een transactie kan alleen database-werk ongedaan maken. Het kan geen e-mail ongedaan maken, geen bestand verwijderen uit cloudopslag of geen betaling terugstorten via een payment API.

Als je een Mail::send() aanroep binnen de transaction closure plaatst en de database draait twee regels later terug, dan komt die e-mail nog steeds aan in de inbox van de klant. De ontvanger heeft nu een factuur voor een bestelling die niet in je systeem bestaat. Hetzelfde geldt voor Slack-notificaties, bestand-uploads naar S3 of webhook-verzendingen.

The correct sequence is:

  1. Complete the transaction and capture any IDs or results you need.
  2. 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.