Making Laravel MongoDB Operations Idempotent: Safe Retries for Financial Transactions
Published on by Arthur Ribeiro
Learn how to implement idempotency in your Laravel MongoDB application to make checkout operations safe to retry, preventing duplicate charges and ensuring reliability even when network failures create uncertainty.
What you'll learn
- Why retries can cause duplicate charges in financial operations
- How to implement idempotency keys in Laravel and MongoDB
- Making financial operations safe to retry after network failures
- Frontend integration patterns for generating and managing idempotency keys
Introduction
In our previous articles, we solved race conditions with atomic operations and ensured multi-document consistency with transactions. Our checkout system is solid—until I tested what happens when networks fail.
Picture this scenario:
- Customer clicks "Buy Now" on your e-commerce site
- Your server processes the checkout transaction successfully
- MongoDB commits the transaction—wallet debited, order created, inventory decreased
- Just before sending the HTTP response, the network connection drops
- Frontend receives a timeout error
- User clicks "Buy Now" again
- Server processes another checkout
- Customer gets charged twice
This isn't theoretical—It happens in production. Networks are unreliable, timeouts occur, and without protection, retries create duplicate charges.
The solution is idempotency—making operations safe to repeat. But here's the key distinction: idempotency keys represent a single user intent, not just a user or a product.
What does "user intent" mean?
- Same intent: User clicks "Buy Now" → Network timeout → User clicks "Buy Now" again
- This is a retry of the same purchase attempt
- Should use the same idempotency key
- Should return the original order, not create a duplicate
- Different intents: User buys 1 laptop today → User buys another laptop tomorrow
- These are two separate purchases
- Should use different idempotency keys
- Should create two separate orders and charge twice
The key insight: A user should be able to buy the same product multiple times (different purchases), while preventing duplicate charges from network retries of the same purchase attempt.
Our implementation will use a compound unique index on (user_id, idempotency_key) to ensure:
- Each user can make unlimited purchases of the same product (with different keys)
- Each specific purchase attempt (identified by a unique key) can only be processed once
- Retries of the same purchase attempt (same key) return the existing order
- Different users can coincidentally generate the same UUID without conflicts
Let's build a system where:
- Clicking "Buy Now" once charges the customer once, even if the network fails 100 times
- Clicking "Buy Now" twice (for two separate purchases) creates two orders and charges twice
The Double-Charge Problem
Let me show you exactly how this happens. Here's our transactional checkout from the previous article:
public function checkout(Request $request){ $validated = $request->validate([ 'user_id' => 'required|string', 'product_id' => 'required|string', 'quantity' => 'required|integer|min:1', ]); $order = DB::connection('mongodb')->transaction(function () use ($validated) { // Debit wallet Wallet::raw(function ($collection) use ($validated) { return $collection->updateOne( ['user_id' => $validated['user_id'], 'balance' => ['$gte' => $amount]], ['$inc' => ['balance' => -$amount]] ); }); // Decrease inventory Product::raw(function ($collection) use ($validated) { return $collection->updateOne( ['_id' => new ObjectId($validated['product_id'])], ['$inc' => ['stock' => -$validated['quantity']]] ); }); // Create order return Order::create([ 'user_id' => $validated['user_id'], 'product_id' => $validated['product_id'], 'quantity' => $validated['quantity'], 'amount' => $amount ]); }); return response()->json(['order' => $order]);}
Now let's simulate a retry scenario:
public function test_retry_without_idempotency_creates_duplicate_charges(){ $product = Product::create([ 'name' => 'Laptop', 'price' => 1000.00, 'stock' => 5 ]); $wallet = Wallet::create([ 'user_id' => 'user-1', 'balance' => 2500.00 ]); // First checkout succeeds $response1 = $this->postJson('/api/checkout', [ 'user_id' => 'user-1', 'product_id' => $product->id, 'quantity' => 1 ]); $response1->assertStatus(200); // Network timeout occurs. Frontend doesn't receive response. // User clicks "Buy Now" again. Second checkout request: $response2 = $this->postJson('/api/checkout', [ 'user_id' => 'user-1', 'product_id' => $product->id, 'quantity' => 1 ]); $response2->assertStatus(200); // Check the damage $wallet->refresh(); $product->refresh(); $orderCount = Order::where('user_id', 'user-1')->count(); dump('=== DOUBLE CHARGE RESULTS ==='); dump('Wallet balance: ' . $wallet->balance); // $500 (should be $1,500!) dump('Orders created: ' . $orderCount); // 2 (should be 1!) dump('Stock remaining: ' . $product->stock); // 3 (correct for 2 orders, but wrong!) $this->assertEquals(2, $orderCount, 'Duplicate order created!'); $this->assertEquals(500.00, $wallet->balance, 'Customer charged twice!');}
Output:
=== DOUBLE CHARGE RESULTS ===Wallet balance: 500Orders created: 2Stock remaining: 3 ✓ test duplicate order created✓ test customer charged twice
The customer was charged $2,000 instead of $1,000. Two orders exist. They'll receive two laptops (or one laptop and a very angry support ticket).
This is the double-charge problem. Retries are necessary in distributed systems, but without idempotency, they're dangerous.
Understanding Idempotency
Idempotency means that performing the same operation multiple times has the same effect as performing it once. In mathematics:
f(x) = f(f(x)) = f(f(f(x)))
For our checkout:
checkout(request) = checkout(checkout(request))
Whether you call checkout once or five times with the same request, the result is the same: one order, one charge, one inventory decrement.
Real-world examples of idempotent operations:
- Setting a value:
user.email = 'new@example.com'(can repeat safely) - Absolute updates:
SET balance = 100(same result every time) - Deleting:
DELETE FROM orders WHERE id = 123(deleting again does nothing)
Non-idempotent operations:
- Incrementing:
balance = balance + 100(different result each time) - Creating records:
INSERT INTO orders VALUES (...)(creates duplicates) - Decrementing:
stock = stock - 1(oversells with retries)
Our checkout is non-idempotent. We need to make it idempotent.
What Are Idempotency Keys?
An idempotency key is a unique identifier that represents a single user action. It's generated once when the user initiates the operation (clicking "Buy Now"), and it's used to recognize duplicate requests.
The key properties:
- Generated by the client (frontend) before making the request
- Unique per user action, not per API call
- Included in the request as a parameter
- Stored with the result in the database
- Used to detect duplicates on subsequent requests
Payment processors like Stripe, Square, and PayPal all require idempotency keys for exactly this reason.
Implementing Idempotency in Laravel
Let's start by updating our Order model to store idempotency keys:
<?php namespace App\Models; use MongoDB\Laravel\Eloquent\Model; class Order extends Model{ protected $connection = 'mongodb'; protected $collection = 'orders'; protected $fillable = [ 'idempotency_key', // ← Add this 'user_id', 'product_id', 'quantity', 'amount', 'status' ]; protected $casts = [ 'amount' => 'decimal:2', 'quantity' => 'integer' ];}
Create a unique index on the idempotency key:
<?php namespace App\Console\Commands; use Illuminate\Console\Command;use Illuminate\Support\Facades\DB; class CreateIdempotencyIndex extends Command{ protected $signature = 'mongo:create-idempotency-index'; protected $description = 'Create unique index on order idempotency keys'; public function handle() { $db = DB::connection('mongodb')->getMongoDB(); try { $db->orders->createIndex( ['idempotency_key' => 1], ['unique' => true] ); $this->info('✓ Created unique index on orders.idempotency_key'); $this->info('Duplicate idempotency keys will now be rejected at database level.'); } catch (\Exception $e) { $this->error('Failed to create index: ' . $e->getMessage()); } }}
Run it:
php artisan mongo:create-idempotency-index
The unique index provides an additional safety net - if duplicate keys somehow reach the database, MongoDB will reject the second one.
Updating the Checkout Controller
Now let's modify our checkout to check for existing orders with the same idempotency key:
Important: We need to scope idempotency keys to individual users to prevent one user from accidentally receiving another user's order.
<?php namespace App\Http\Controllers; use App\Models\Wallet;use App\Models\Product;use App\Models\Order;use Illuminate\Http\Request;use Illuminate\Support\Facades\DB;use MongoDB\BSON\ObjectId; class CheckoutController extends Controller{ public function checkout(Request $request) { $validated = $request->validate([ 'user_id' => 'required|string', 'product_id' => 'required|string', 'quantity' => 'required|integer|min:1', 'idempotency_key' => 'required|string|size:36', // UUID format ]); $userId = $validated['user_id']; $productId = $validated['product_id']; $quantity = $validated['quantity']; $idempotencyKey = $validated['idempotency_key']; try { $order = DB::connection('mongodb')->transaction(function () use ($userId, $productId, $quantity, $idempotencyKey) { // STEP 1: Check if THIS USER already processed this request // Scope by BOTH user_id AND idempotency_key $existingOrder = Order::where('user_id', $userId) ->where('idempotency_key', $idempotencyKey) ->first(); if ($existingOrder) { \Log::info('Idempotent request detected', [ 'idempotency_key' => $idempotencyKey, 'order_id' => $existingOrder->id, 'user_id' => $userId ]); // Return the existing order without creating a duplicate return $existingOrder; } // STEP 2: This is a new request, proceed with checkout $product = Product::findOrFail($productId); $amount = $product->price * $quantity; // Debit wallet atomically $walletResult = Wallet::raw(function ($collection) use ($userId, $amount) { return $collection->updateOne( [ 'user_id' => $userId, 'balance' => ['$gte' => $amount] ], [ '$inc' => ['balance' => -$amount] ] ); }); if ($walletResult->getModifiedCount() === 0) { throw new \Exception('Insufficient funds'); } // Decrease inventory atomically $inventoryResult = Product::raw(function ($collection) use ($productId, $quantity) { return $collection->updateOne( [ '_id' => new ObjectId($productId), 'stock' => ['$gte' => $quantity] ], [ '$inc' => ['stock' => -$quantity] ] ); }); if ($inventoryResult->getModifiedCount() === 0) { throw new \Exception('Insufficient stock'); } // Create order WITH idempotency key return Order::create([ 'idempotency_key' => $idempotencyKey, 'user_id' => $userId, 'product_id' => $productId, 'quantity' => $quantity, 'amount' => $amount, 'status' => 'completed', 'created_at' => now() ]); }); return response()->json([ 'success' => true, 'order' => $order, 'is_duplicate' => !$order->wasRecentlyCreated ]); } catch (\Exception $e) { return response()->json([ 'error' => $e->getMessage() ], 400); } }}
The key changes:
1. Validate idempotency_key:
'idempotency_key' => 'required|string|size:36'
2. Check for existing orders FIRST:
// WRONG - Different users could have the same UUID$existingOrder = Order::where('idempotency_key', $idempotencyKey)->first(); // CORRECT - Scope by both user_id and idempotency_key$existingOrder = Order::where('user_id', $userId) ->where('idempotency_key', $idempotencyKey) ->first(); if ($existingOrder) { return $existingOrder; // Early return, skip all processing}
3. Store the key with the order:
Order::create([ 'idempotency_key' => $idempotencyKey, // ... other fields]);
Now if the same idempotency key comes through multiple times, we return the existing order without creating duplicates or charging again.
Testing Idempotency
Let's verify it works:
<?php namespace Tests\Feature; use Tests\TestCase;use App\Models\Wallet;use App\Models\Product;use App\Models\Order;use Illuminate\Support\Str; class IdempotencyTest extends TestCase{ protected function setUp(): void { parent::setUp(); Wallet::truncate(); Product::truncate(); Order::truncate(); } public function test_idempotency_prevents_duplicate_charges() { $product = Product::create([ 'name' => 'Laptop', 'price' => 1000.00, 'stock' => 5 ]); $wallet = Wallet::create([ 'user_id' => 'user-1', 'balance' => 2500.00 ]); // Generate idempotency key ONCE $idempotencyKey = Str::uuid()->toString(); // First request $response1 = $this->postJson('/api/checkout', [ 'user_id' => 'user-1', 'product_id' => $product->id, 'quantity' => 1, 'idempotency_key' => $idempotencyKey ]); $response1->assertStatus(200); $response1->assertJsonPath('is_duplicate', false); // Simulate retry with SAME idempotency key $response2 = $this->postJson('/api/checkout', [ 'user_id' => 'user-1', 'product_id' => $product->id, 'quantity' => 1, 'idempotency_key' => $idempotencyKey // ← Same key! ]); $response2->assertStatus(200); $response2->assertJsonPath('is_duplicate', true); // Both responses should return the SAME order $this->assertEquals( $response1->json('order.id'), $response2->json('order.id'), 'Should return the same order for duplicate requests' ); // Verify data integrity $wallet->refresh(); $product->refresh(); $orderCount = Order::where('user_id', 'user-1')->count(); $this->assertEquals(1500.00, $wallet->balance, 'Charged only once'); $this->assertEquals(4, $product->stock, 'Decremented only once'); $this->assertEquals(1, $orderCount, 'Only one order created'); } public function test_different_idempotency_keys_create_separate_orders() { $product = Product::create([ 'name' => 'Laptop', 'price' => 1000.00, 'stock' => 5 ]); $wallet = Wallet::create([ 'user_id' => 'user-1', 'balance' => 3000.00 ]); // First order with first key $key1 = Str::uuid()->toString(); $response1 = $this->postJson('/api/checkout', [ 'user_id' => 'user-1', 'product_id' => $product->id, 'quantity' => 1, 'idempotency_key' => $key1 ]); // Second order with different key $key2 = Str::uuid()->toString(); $response2 = $this->postJson('/api/checkout', [ 'user_id' => 'user-1', 'product_id' => $product->id, 'quantity' => 1, 'idempotency_key' => $key2 ]); $response1->assertStatus(200); $response2->assertStatus(200); // Different orders should be created $this->assertNotEquals( $response1->json('order.id'), $response2->json('order.id'), 'Different keys should create different orders' ); // Both charges should go through $wallet->refresh(); $orderCount = Order::where('user_id', 'user-1')->count(); $this->assertEquals(1000.00, $wallet->balance, 'Charged twice for two orders'); $this->assertEquals(2, $orderCount, 'Two orders created'); } public function test_concurrent_requests_with_same_key_create_one_order() { $product = Product::create([ 'name' => 'Laptop', 'price' => 1000.00, 'stock' => 5 ]); $wallet = Wallet::create([ 'user_id' => 'user-1', 'balance' => 2500.00 ]); $idempotencyKey = Str::uuid()->toString(); // Send 5 TRULY CONCURRENT requests with the same idempotency key // Using Laravel's HTTP client pool for parallel execution $promises = []; for ($i = 0; $i < 5; $i++) { $promises[] = Http::async()->post(url('/api/checkout'), [ 'user_id' => 'user-1', 'product_id' => $product->id, 'quantity' => 1, 'idempotency_key' => $idempotencyKey ]); } // Execute all requests in parallel and wait for completion $responses = Http::pool(fn ($pool) => $promises); // All should succeed (200) foreach ($responses as $response) { $this->assertEquals(200, $response->status(), 'All requests should succeed'); } // All should return the same order ID $orderIds = collect($responses) ->map(fn($r) => $r->json('order.id')) ->unique(); $this->assertEquals(1, $orderIds->count(), 'All concurrent requests should return the same order'); // Only one charge $wallet->refresh(); $orderCount = Order::where('user_id', 'user-1')->count(); $this->assertEquals(1500.00, $wallet->balance, 'Charged only once'); $this->assertEquals(1, $orderCount, 'Only one order exists'); }}
Run the tests:
php artisan test --filter=IdempotencyTest
All tests should pass, confirming:
- ✅ Duplicate idempotency keys return the same order
- ✅ Different keys create separate orders
- ✅ Concurrent requests with the same key create only one order
- ✅ Wallet is only charged once per unique key
Frontend Integration: Generating Idempotency Keys
On the frontend, generate the idempotency key when the user initiates checkout, not when making the API call. This ensures retries use the same key.
Vue.js example:
<template> <div> <button @click="handleCheckout" :disabled="isProcessing" > {{ isProcessing ? 'Processing...' : 'Buy Now' }} </button> <div v-if="error" class="error"> {{ error }} <button @click="retryCheckout">Retry</button> </div> </div></template> <script>export default { data() { return { isProcessing: false, error: null, idempotencyKey: null } }, methods: { handleCheckout() { // Generate idempotency key ONCE when user clicks this.idempotencyKey = crypto.randomUUID(); this.performCheckout(); }, async performCheckout() { this.isProcessing = true; this.error = null; try { const response = await axios.post('/api/checkout', { user_id: this.userId, product_id: this.productId, quantity: 1, idempotency_key: this.idempotencyKey // ← Same key for retries }); this.$emit('checkout-success', response.data.order); } catch (error) { if (error.code === 'ECONNABORTED' || error.response?.status === 500) { // Network timeout or server error - safe to retry this.error = 'Network error. Please retry.'; } else { // Business error (insufficient funds, out of stock) this.error = error.response?.data?.error || 'Checkout failed'; this.idempotencyKey = null; // Reset key for new attempt } } finally { this.isProcessing = false; } }, retryCheckout() { // Retry with the SAME idempotency key this.performCheckout(); } }}</script>
React example:
import { useState } from 'react';import axios from 'axios'; function CheckoutButton({ userId, productId }) { const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null); const [idempotencyKey, setIdempotencyKey] = useState(null); const handleCheckout = () => { // Generate idempotency key when user clicks const key = crypto.randomUUID(); setIdempotencyKey(key); performCheckout(key); }; const performCheckout = async (key) => { setIsProcessing(true); setError(null); try { const response = await axios.post('/api/checkout', { user_id: userId, product_id: productId, quantity: 1, idempotency_key: key // ← Use the key }, { timeout: 10000 // 10 second timeout }); onCheckoutSuccess(response.data.order); } catch (err) { if (err.code === 'ECONNABORTED' || err.response?.status === 500) { // Network error - safe to retry with same key setError('Network error. Click retry.'); } else { // Business error - need new key for new attempt setError(err.response?.data?.error || 'Checkout failed'); setIdempotencyKey(null); } } finally { setIsProcessing(false); } }; const retryCheckout = () => { // Retry with the SAME idempotency key performCheckout(idempotencyKey); }; return ( <div> <button onClick={handleCheckout} disabled={isProcessing} > {isProcessing ? 'Processing...' : 'Buy Now'} </button> {error && ( <div className="error"> {error} {idempotencyKey && ( <button onClick={retryCheckout}>Retry</button> )} </div> )} </div> );}
Plain JavaScript example:
class CheckoutHandler { constructor(userId, productId) { this.userId = userId; this.productId = productId; this.idempotencyKey = null; } async handleCheckout() { // Generate key when user initiates this.idempotencyKey = crypto.randomUUID(); return this.performCheckout(); } async performCheckout() { try { const response = await fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: this.userId, product_id: this.productId, quantity: 1, idempotency_key: this.idempotencyKey }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.message); } const data = await response.json(); return data.order; } catch (error) { if (error.name === 'TypeError' || error.message.includes('network')) { // Network error - can retry with same key console.log('Network error, safe to retry'); throw error; } else { // Business error - need new key this.idempotencyKey = null; throw error; } } } async retry() { // Retry with same idempotency key if (!this.idempotencyKey) { throw new Error('No idempotency key to retry'); } return this.performCheckout(); }} // Usageconst checkout = new CheckoutHandler('user-123', 'product-456'); document.getElementById('checkout-btn').addEventListener('click', async () => { try { const order = await checkout.handleCheckout(); showSuccess(order); } catch (error) { showError(error.message); // Show retry button for network errors if (checkout.idempotencyKey) { document.getElementById('retry-btn').style.display = 'block'; } }}); document.getElementById('retry-btn').addEventListener('click', async () => { try { const order = await checkout.retry(); showSuccess(order); } catch (error) { showError(error.message); }});
Key principles:
- Generate key on user action (button click), not API call
- Store key in component state for retries
- Reuse same key for network errors
- Generate new key for business errors (insufficient funds requires new attempt)
- Show retry button only when safe to retry
Idempotency Key Lifecycle Management
How long should you keep idempotency keys in the database? There are two approaches:
Approach 1: Keep keys indefinitely (simpler)
// No cleanup needed// Keys stay in database forever// Disk space is cheap// Provides complete audit trail
Approach 2: TTL cleanup (more complex)
<?php namespace App\Console\Commands; use Illuminate\Console\Command;use App\Models\Order; class CleanupOldIdempotencyKeys extends Command{ protected $signature = 'idempotency:cleanup'; protected $description = 'Remove idempotency keys older than 30 days'; public function handle() { $thirtyDaysAgo = now()->subDays(30); // Option 1: Remove the key field from old orders Order::where('created_at', '<', $thirtyDaysAgo) ->update(['idempotency_key' => null]); // Option 2: Use MongoDB TTL index (set at collection level) // This automatically removes old orders entirely $db = DB::connection('mongodb')->getMongoDB(); $db->orders->createIndex( ['created_at' => 1], ['expireAfterSeconds' => 2592000] // 30 days ); $this->info('Cleaned up old idempotency keys'); }}
Schedule it in app/Console/Kernel.php:
protected function schedule(Schedule $schedule){ $schedule->command('idempotency:cleanup') ->daily() ->at('02:00');}
My recommendation: Keep keys indefinitely unless you have specific compliance requirements. The storage cost is minimal, and keeping keys provides a complete audit trail.
Monitoring and Debugging Idempotency
Add logging to understand how often duplicate requests occur:
public function checkout(Request $request){ // ... validation ... $order = DB::connection('mongodb')->transaction(function () use ($validated) { $idempotencyKey = $validated['idempotency_key']; $existingOrder = Order::where('idempotency_key', $idempotencyKey)->first(); if ($existingOrder) { // Log duplicate attempt \Log::info('Duplicate checkout attempt prevented', [ 'idempotency_key' => $idempotencyKey, 'user_id' => $validated['user_id'], 'product_id' => $validated['product_id'], 'original_order_id' => $existingOrder->id, 'original_created_at' => $existingOrder->created_at, 'time_since_original' => now()->diffInSeconds($existingOrder->created_at) ]); return $existingOrder; } // ... proceed with checkout ... }); return response()->json([ 'order' => $order ]);}
Create a dashboard query to monitor duplicate rates:
// In a monitoring controller or commandpublic function getDuplicateCheckoutStats(){ $logs = DB::connection('mongodb') ->collection('logs') ->where('message', 'Duplicate checkout attempt prevented') ->where('created_at', '>=', now()->subHours(24)) ->get(); return [ 'total_duplicates_24h' => $logs->count(), 'avg_retry_delay' => $logs->avg('time_since_original'), 'users_with_retries' => $logs->pluck('user_id')->unique()->count() ];}
If you see high duplicate rates, investigate:
- Are your timeout settings too aggressive?
- Is your network infrastructure unstable?
- Are users clicking multiple times due to slow UI feedback?
Beyond Checkout: Other Use Cases for Idempotency
Idempotency isn't just for checkout. Use it for any operation where duplicates would be problematic:
Refunds:
public function refund(Request $request){ $validated = $request->validate([ 'order_id' => 'required|string', 'amount' => 'required|numeric', 'idempotency_key' => 'required|string|size:36' ]); DB::connection('mongodb')->transaction(function () use ($validated) { // Check for existing refund with this key $existingRefund = Refund::where('idempotency_key', $validated['idempotency_key']) ->first(); if ($existingRefund) { return $existingRefund; } // Process refund... });}
Subscription charges:
public function chargeSubscription(Request $request){ $validated = $request->validate([ 'subscription_id' => 'required|string', 'period' => 'required|string', // e.g., "2024-01" 'idempotency_key' => 'required|string|size:36' ]); // Idempotency prevents charging the same period twice}
Account transfers:
public function transfer(Request $request){ $validated = $request->validate([ 'from_account' => 'required|string', 'to_account' => 'required|string', 'amount' => 'required|numeric', 'idempotency_key' => 'required|string|size:36' ]); // Idempotency prevents duplicate transfers}
General principle: Any financial operation that would cause problems if executed multiple times should be idempotent.
Key Takeaways
- Idempotency makes operations safe to retry - The same request processed multiple times produces the same result as processing it once
- Generate keys on the frontend when the user acts - Create the UUID when the user clicks "Buy Now," not when making the API call
- Check for existing operations before processing - Inside your transaction, look for orders with the same idempotency key first
- Use unique indexes to enforce idempotency at the database level - MongoDB will reject duplicate keys, providing an additional safety net
- Reuse keys for network errors, generate new keys for business errors - Retry timeouts with the same key, but insufficient funds needs a new attempt
- Implement idempotency for all financial operations - Checkouts, refunds, transfers, subscription charges all need protection from duplicates
- Monitor duplicate request rates - High rates indicate infrastructure issues or UX problems that need addressing
- Keep keys indefinitely for audit trails - Storage is cheap, and keeping keys provides complete transaction history
FAQs
How do I generate idempotency keys in Laravel?
Use Illuminate\Support\Str::uuid()->toString() on the backend or crypto.randomUUID() on the frontend. Generate the key when the user initiates the action (clicking "Buy Now"), not when making the API call. Pass it as a request parameter, validate it with 'idempotency_key' => 'required|string|size:36', and store it with your order or transaction record. The key should be unique per user action.
Should I generate idempotency keys on the frontend or backend?
Generate them on the frontend when the user initiates the action. This ensures that if the frontend retries due to a network timeout, it uses the same key. If you generate keys on the backend, each retry would get a new key, defeating the purpose. The frontend generates once, then reuses that key for all retry attempts of the same user action.
How long should I keep idempotency keys in my database?
You have two options: (1) Keep them indefinitely—storage is cheap and you get a complete audit trail, or (2) Clean them up after 30-90 days using a scheduled task or MongoDB TTL index. Most applications should keep keys indefinitely unless you have specific compliance requirements. The disk space cost is minimal compared to the value of having complete transaction history.
What happens if two requests use the same idempotency key simultaneously?
MongoDB's unique index on idempotency_key ensures that even if two requests reach your server at exactly the same time, only one will successfully create the order. The second request will either wait (if inside a transaction) or receive a duplicate key error. Your code's check for existing orders (Order::where('idempotency_key', $key)->first()) will catch this and return the existing order. One request wins, all others get the same result.
Can I use idempotency for non-financial operations?
Yes, but it's less critical. Use idempotency for any operation where duplicates would cause problems: user registrations (creating multiple accounts), email sending (spamming users), webhook processing (handling the same event twice), or file uploads (creating duplicates). For read-only operations or operations where duplicates are harmless (like logging analytics), idempotency isn't necessary.
Conclusion: Building Production-Ready Laravel MongoDB Applications
Over these three articles, we've built a complete, production-ready checkout system:
Part 1 taught us to eliminate race conditions using MongoDB's atomic operators:
Wallet::raw(fn($c) => $c->updateOne( ['user_id' => $userId, 'balance' => ['$gte' => $amount]], ['$inc' => ['balance' => -$amount]]));
Part 2 showed us how to maintain consistency across multiple collections using transactions:
DB::connection('mongodb')->transaction(function () { // Wallet debit, inventory update, order creation - all atomic});
Part 3 made our operations safe to retry using idempotency keys:
$existing = Order::where('idempotency_key', $key)->first();if ($existing) return $existing;
Together, these techniques create a system that's:
- Race-condition free (atomic operations)
- Consistent (transactions)
- Reliable (idempotent)
- Production-ready (tested and battle-hardened)
Your Laravel MongoDB applications can now handle:
- Thousands of concurrent users
- Network failures and retries
- Complex multi-document operations
- Financial transactions with confidence
References