Wat idempotentie precies is
Idempotentie is een wiskundige eigenschap die in software-architectuur uitgegroeid is tot een fundament onder betrouwbare systemen. De definitie is kort: een operatie is idempotent als het resultaat hetzelfde is na één keer uitvoeren als na honderd keer uitvoeren. De toestand verandert bij de eerste oproep; alle volgende oproepen veranderen niets meer.
Een vertrouwd voorbeeld helpt. Het indrukken van een liftknop is idempotent: hoe vaak je ook drukt, er komt één lift. Het tegelijk laten rinkelen van een wekker tien keer instellen is dat niet — dan rinkelt hij tien keer. Het verschil zit in de semantiek van de operatie, niet in de techniek erachter.
In software vertaalt zich dat naar concrete patronen. Een API-call die de status van een order op betaald zet, mag honderd keer binnenkomen — de order blijft betaald. Een API-call die een nieuwe order aanmaakt, levert bij honderd uitvoeringen honderd orders op. De eerste is idempotent, de tweede niet.
Waarom het ertoe doet
In een ideale wereld komt elk verzoek precies één keer aan en wordt het precies één keer verwerkt. De werkelijkheid is grilliger. Netwerken stotteren, servers timen out, queues retryen, gebruikers klikken dubbel op submit-knoppen, mobiele apps verzenden pending requests opnieuw zodra de verbinding terug is.
Het gevolg: bijna elke operatie in een productieomgeving wordt vroeg of laat dubbel aangeroepen. De vraag is niet of dat gebeurt, maar wat je systeem doet als het gebeurt. Een idempotent endpoint haalt z'n schouders op. Een niet-idempotent endpoint maakt twee orders, boekt twee betalingen of stuurt twee facturen.
De pijnpunten zitten meestal in dezelfde drie hoeken:
- Betalingen. Een dubbele afschrijving terugboeken kost klanten vertrouwen en jou verwerkingstijd.
- Communicatie. Twee identieke bestelbevestigingen of facturen kosten minder geld, maar vreten net zo hard aan reputatie.
- Voorraad en logistiek. Een dubbele order leidt tot dubbele picks, dubbele verzendingen en hopeloze klantenservicegesprekken.
Een idempotent ontwerp is niet duurder om te bouwen — het is alleen duurder om er te laat aan te beginnen.
Idempotentie in HTTP
De HTTP-specificatie heeft idempotentie als eerste-orde-concept ingebouwd. Dat is geen historisch toeval; het bepaalt hoe browsers, proxies en client libraries omgaan met retries.
Idempotente methodes: GET, HEAD, PUT, DELETE en OPTIONS. Een client mag deze veilig opnieuw versturen als een eerder antwoord verloren is gegaan. Browsers en HTTP-libraries doen dat ook automatisch bij netwerktimeouts.
Niet-idempotente methodes: POST en PATCH. Bij deze methodes weet de client niet of een herhaald verzoek veilig is, dus retries worden meestal niet automatisch uitgevoerd.
Het verschil tussen POST en PUT is daarmee niet alleen semantisch. Een PUT /orders/123/status die het veld op betaald zet, is per definitie veilig om te herhalen. Een POST /orders die een nieuwe order aanmaakt, is dat niet.
Belangrijk om te beseffen: idempotentie is een belofte die jij als ontwikkelaar nakomt, niet een eigenschap die de specificatie afdwingt. Een verkeerd geschreven PUT-handler die per call een audit-record toevoegt, gedraagt zich niet idempotent — ook al heet de methode zo. De semantiek moet ook in je code zitten, niet alleen in je router.
Idempotency keys
Voor operaties die van nature niet idempotent zijn — een betaling starten, een order aanmaken — gebruik je een idempotency key. Het patroon is gepopulariseerd door Stripe en inmiddels overgenomen door vrijwel elke serieuze betaal- en transactie-API.
De werking is rechttoe rechtaan. De client genereert per logische operatie een unieke sleutel — meestal een UUID — en stuurt die mee in een header. De server slaat die sleutel op samen met de respons. Komt hetzelfde verzoek nogmaals binnen met dezelfde sleutel, dan retourneert de server het opgeslagen antwoord zonder de operatie opnieuw uit te voeren.
De client-kant in PHP ziet er zo uit:
Http::withHeaders([
'Idempotency-Key' => (string) Str::uuid(),
])->post('https://api.stripe.com/v1/payment_intents', [
'amount' => 2500,
'currency' => 'eur',
]);
Cruciaal: de sleutel wordt door de client gegenereerd en hergebruikt bij retries van diezelfde logische operatie. Als de client bij elke retry een nieuwe sleutel maakt, vervalt de bescherming volledig — dan zijn het vanuit het serverperspectief twee verschillende operaties.
Implementatie in Laravel
Een eigen idempotency-laag bouw je in Laravel meestal als middleware die binnenkomende verzoeken onderschept. De kern is een tabel die idempotency-sleutels koppelt aan opgeslagen responses.
Schema::create('idempotency_keys', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->string('request_hash');
$table->json('response');
$table->unsignedSmallInteger('status_code');
$table->timestamps();
});
De middleware-logica volgt drie stappen:
- Lees de
Idempotency-Key-header. Ontbreekt deze bij een gevoelige operatie, dan kun je hem verplichten of het verzoek toch doorlaten. - Zoek de sleutel op in de database. Bestaat hij al, retourneer dan het opgeslagen antwoord — eventueel na controle of de request body identiek is, om misbruik te voorkomen.
- Bestaat de sleutel niet, voer dan de request normaal uit en sla het resultaat op vóór je antwoordt. Wikkel dit in een database-transactie zodat een crash midden in de verwerking de sleutel niet vastpint op een onvolledige response.
Het vangnet is de unique-constraint op de key-kolom. Bij twee gelijktijdige requests met dezelfde sleutel — een race condition die in productie echt voorkomt — faalt een van de twee inserts op de database-constraint. Dat is geen bug; dat is precies de bedoeling. De afgewezen request kan dan netjes het opgeslagen antwoord van de winnaar ophalen.
Idempotentie in de database
Niet elke idempotente operatie heeft een aparte sleutel nodig. Vaak kun je het datamodel zo ontwerpen dat herhaling inherent veilig is.
Upserts. Een updateOrCreate op basis van een externe identifier is van nature idempotent. Bestaat het record al, dan wordt het bijgewerkt; bestaat het niet, dan wordt het aangemaakt. Het tien keer aanroepen van dezelfde upsert leidt tot exact één record.
Order::updateOrCreate(
['external_id' => $externalId],
['amount' => $amount, 'status' => 'pending'],
);
Unique constraints. Leg op kolommen die per definitie uniek moeten zijn — een externe order-ID, een webhook-event-ID, een combinatie van klant en factuurnummer — een database-constraint. Dat geeft een keiharde garantie die niet afhangt van applicatielogica. Twee processen die tegelijkertijd hetzelfde record proberen aan te maken, leveren één succesvolle insert en één duidelijke fout op.
State machines. Modelleer de levenscyclus van entiteiten als toestanden met expliciete transities. Een order kan van pending naar paid, maar niet van paid naar paid. Een tweede betalings-event op een al betaalde order wordt dan vanzelf een no-op in plaats van een dubbele administratie.
Queue-jobs en retries
Achtergrond-jobs in Laravel worden bij failures automatisch opnieuw geprobeerd. Dat is een feature, geen bug — maar het maakt idempotentie van je job-handlers verplicht in plaats van wenselijk.
De stelregel: ga er bij elke job vanuit dat hij minstens één keer zal falen en opnieuw zal lopen, ook als de eerste run technisch slaagde. Een payment-webhook-job die na tien seconden timeout maar wel de betaling registreerde, krijgt bij de retry exact dezelfde input opnieuw aangereikt.
Concreet betekent dat: gebruik unieke jobs (ShouldBeUnique) waar passend, controleer aan het begin van de handler of de actie al is uitgevoerd, en wikkel database-werk in transacties. De combinatie van een unique constraint op het event-ID en een check vooraf vangt de meeste retry-scenario's af.
public function handle(): void
{
if (PaymentEvent::where('event_id', $this->eventId)->exists()) {
return;
}
DB::transaction(function () {
PaymentEvent::create(['event_id' => $this->eventId]);
$this->applyPayment();
});
}
Best practices
Een paar regels die zich in productie keer op keer bewijzen:
- Maak idempotentie een ontwerpkeuze, geen reparatie. Stel bij elk endpoint en elke job de vraag: wat gebeurt er als dit twee keer binnenkomt? Doe dat in de ontwerpfase, niet pas wanneer het probleem zich voordoet.
- Gebruik database-constraints als laatste vangnet. Applicatielogica kan falen, race conditions kunnen onverwacht slaan. Een unique constraint maakt dubbele records simpelweg onmogelijk, ongeacht wat de code doet.
- Houd idempotency keys lang genoeg vast. Bewaar ze minimaal 24 uur, vaak langer. Sommige clients retryen pas na uren wanneer een verbinding hersteld is.
- Hash de request body bij gebruik van keys. Sla naast de sleutel een hash van de payload op. Komt later dezelfde sleutel binnen met een andere body, dan is dat een fout in de client en moet je een 422 teruggeven in plaats van het oude antwoord.
- Test idempotentie expliciet. Schrijf een test die elke kritieke endpoint twee keer met dezelfde input aanroept en controleert dat de toestand identiek is na de eerste en tweede call. Zonder die test is het altijd kwestie van tijd voor er iets sluipend doorheen glipt.
Conclusie
Idempotentie klinkt als een academisch concept, maar het is een van de meest praktische principes in betrouwbare software. Elke API, elke queue-job, elke webhook-handler komt vroeg of laat in een situatie terecht waarin een verzoek dubbel binnenkomt. De systemen die daar moeiteloos mee omgaan, zijn de systemen waarin idempotentie van het begin af aan onderdeel van het ontwerp was.
De technische ingrediënten zijn niet ingewikkeld: idempotente HTTP-methodes waar het kan, idempotency keys waar het moet, unique constraints als vangnet, en transactionele verwerking om alles bij elkaar te houden. De moeilijkheid zit niet in de implementatie maar in de discipline om de vraag "wat als dit twee keer gebeurt?" consistent te stellen — bij elk endpoint, elke job, elke integratie.