Laravel Cloud is here! Zero-config managed infrastructure for Laravel apps. Deploy now.

Making Laravel MongoDB Operations Idempotent: Safe Retries for Financial Transactions

Published on by

Making Laravel MongoDB Operations Idempotent: Safe Retries for Financial Transactions image

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:

  1. Customer clicks "Buy Now" on your e-commerce site
  2. Your server processes the checkout transaction successfully
  3. MongoDB commits the transaction—wallet debited, order created, inventory decreased
  4. Just before sending the HTTP response, the network connection drops
  5. Frontend receives a timeout error
  6. User clicks "Buy Now" again
  7. Server processes another checkout
  8. 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: 500
Orders created: 2
Stock 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:

  1. Generated by the client (frontend) before making the request
  2. Unique per user action, not per API call
  3. Included in the request as a parameter
  4. Stored with the result in the database
  5. 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();
}
}
 
// Usage
const 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:

  1. Generate key on user action (button click), not API call
  2. Store key in component state for retries
  3. Reuse same key for network errors
  4. Generate new key for business errors (insufficient funds requires new attempt)
  5. 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 command
public 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

Arthur Ribeiro photo

Delbridge Consultant

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
Laravel Code Review

Get expert guidance in a few days with a Laravel code review

Visit Laravel Code Review
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
Get expert guidance in a few days with a Laravel code review logo

Get expert guidance in a few days with a Laravel code review

Expert code review! Get clear, practical feedback from two Laravel devs with 10+ years of experience helping teams build better apps.

Get expert guidance in a few days with a Laravel code review
PhpStorm logo

PhpStorm

The go-to PHP IDE with extensive out-of-the-box support for Laravel and its ecosystem.

PhpStorm
Laravel Cloud logo

Laravel Cloud

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Cloud
Acquaint Softtech logo

Acquaint Softtech

Acquaint Softtech offers AI-ready Laravel developers who onboard in 48 hours at $3000/Month with no lengthy sales process and a 100 percent money-back guarantee.

Acquaint Softtech
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Harpoon: Next generation time tracking and invoicing logo

Harpoon: Next generation time tracking and invoicing

The next generation time-tracking and billing software that helps your agency plan and forecast a profitable future.

Harpoon: Next generation time tracking and invoicing
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
MongoDB logo

MongoDB

Enhance your PHP applications with the powerful integration of MongoDB and Laravel, empowering developers to build applications with ease and efficiency. Support transactional, search, analytics and mobile use cases while using the familiar Eloquent APIs. Discover how MongoDB's flexible, modern database can transform your Laravel applications.

MongoDB

The latest

View all →
Laravel Cloud Adds Path Blocking to Prevent Bots From Waking Hibernated Apps image

Laravel Cloud Adds Path Blocking to Prevent Bots From Waking Hibernated Apps

Read article
Making Laravel MongoDB Operations Idempotent: Safe Retries for Financial Transactions image

Making Laravel MongoDB Operations Idempotent: Safe Retries for Financial Transactions

Read article
FormRequest Strict Mode and Queue Job Inspection in Laravel 13.4.0 image

FormRequest Strict Mode and Queue Job Inspection in Laravel 13.4.0

Read article
Pretty PHP Info: A Modern Replacement for `phpinfo()` image

Pretty PHP Info: A Modern Replacement for `phpinfo()`

Read article
Laracon US 2026 Announced image

Laracon US 2026 Announced

Read article
Laravel QuickBooks MCP Server: Connect QuickBooks Online to AI Clients image

Laravel QuickBooks MCP Server: Connect QuickBooks Online to AI Clients

Read article