Polyscope - The agent-first dev environment for Laravel

Making Laravel MongoDB Operations Idempotent: Safe Retries for Financial Transactions

Published on 经过

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

介绍

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 重试 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:

民众 功能 checkout 要求 $request)
{
$已验证 = $请求 -> 证实 ([
'user_id' => 'required|string' ,
'product_id' => 'required|string' ,
'quantity' => 'required|integer|min:1' ,
]);
$订单 = 数据库 :: 联系 'mongodb' -> 交易 功能 () 使用 ($validated) {
// Debit wallet
Wallet :: 生的 功能 ($collection) 使用 ($validated) {
返回 $集合 -> updateOne
[ 'user_id' => $validated[ 'user_id' ], 'balance' => [ '$gte' => $amount]],
[ '$inc' => [ 'balance' => - $amount]]
(英文):
});
// Decrease inventory
产品 :: 生的 功能 ($collection) 使用 ($validated) {
返回 $集合 -> updateOne
[ '_id' => 新的 ObjectId ($validated[ 'product_id' ])],
[ '$inc' => [ 'stock' => - $validated[ 'quantity' ]]]
(英文):
});
// Create order
返回 命令 :: 创造 ([
'user_id' => $validated[ 'user_id' ],
'product_id' => $validated[ 'product_id' ],
'quantity' => $validated[ 'quantity' ],
'amount' => $amount
]);
});
返回 回复 () -> json ([ 'order' => $order]);
}

Now let's simulate a retry scenario:

民众 功能 test_retry_without_idempotency_creates_duplicate_charges ()
{
$product = 产品 :: 创造 ([
'姓名' => 'Laptop' ,
'price' => 1000.00 ,
'stock' => 5
]);
$wallet = Wallet :: 创造 ([
'user_id' => 'user-1' ,
'balance' => 2500.00
]);
// First checkout succeeds
$response1 = $this -> 帖子Json '/api/checkout' ,[
'user_id' => 'user-1' ,
'product_id' => $product -> id,
'quantity' => 1
]);
$response1 -> 断言状态 200 (英文):
// Network timeout occurs. Frontend doesn't receive response.
// User clicks "Buy Now" again. Second checkout request:
$response2 = $this -> 帖子Json '/api/checkout' ,[
'user_id' => 'user-1' ,
'product_id' => $product -> id,
'quantity' => 1
]);
$response2 -> 断言状态 200 (英文):
// Check the damage
$wallet -> 刷新 ();
$product -> 刷新 ();
$orderCount = 命令 :: 在哪里 'user_id' , 'user-1' -> 数数 ();
倾倒 '=== DOUBLE CHARGE RESULTS ===' (英文):
倾倒 'Wallet balance: ' $wallet -> balance); // $500 (should be $1,500!)
倾倒 'Orders created: ' $orderCount); // 2 (should be 1!)
倾倒 'Stock remaining: ' $product -> stock); // 3 (correct for 2 orders, but wrong!)
$this -> 断言等于 2 , $orderCount, 'Duplicate order created!' (英文):
$this -> 断言等于 500.00 , $wallet -> balance, 'Customer charged twice!' (英文):
}

输出:

=== 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?

一个 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:

命名空间 应用程序\模型 ;
使用 MongoDB\Laravel\Eloquent\Model ;
班级 命令 延伸 模型
{
受保护 $连接 = 'mongodb' ;
受保护 $集合 = 'orders' ;
受保护 $fillable = [
'idempotency_key' , // ← Add this
'user_id' ,
'product_id' ,
'quantity' ,
'amount' ,
'status'
];
受保护 $casts = [
'amount' => 'decimal:2' ,
'quantity' => 'integer'
];
}

Create a unique index on the idempotency key:

命名空间 App\Console\Commands ;
使用 Illuminate\Console\Command ;
使用 Illuminate\Support\Facades\DB ;
班级 CreateIdempotencyIndex 延伸 命令
{
受保护 $signature = 'mongo:create-idempotency-index' ;
受保护 $description = 'Create unique index on order idempotency keys' ;
民众 功能 处理 ()
{
$db = 数据库 :: 联系 'mongodb' -> getMongoDB ();
尝试 {
$db -> 订单 -> createIndex
[ 'idempotency_key' => 1 ],
[ 'unique' => 真的 ]
(英文):
$this -> 信息 '✓ Created unique index on orders.idempotency_key' (英文):
$this -> 信息 'Duplicate idempotency keys will now be rejected at database level.' (英文):
} 抓住 \Exception $e) {
$this -> 错误 'Failed to create index: ' $e -> 获取消息 ());
}
}
}

Run it:

php 工匠 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:

重要的 : We need to scope idempotency keys to individual users to prevent one user from accidentally receiving another user's order.

命名空间 App\Http\Controllers ;
使用 App\Models\Wallet ;
使用 App\Models\Product ;
使用 App\Models\Order ;
使用 照亮\Http\请求 ;
使用 Illuminate\Support\Facades\DB ;
使用 MongoDB\BSON\ObjectId ;
班级 CheckoutController 延伸 控制器
{
民众 功能 checkout 要求 $request)
{
$已验证 = $请求 -> 证实 ([
'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' ];
尝试 {
$订单 = 数据库 :: 联系 'mongodb' -> 交易 功能 () 使用 ($userId, $productId, $quantity, $idempotencyKey) {
// STEP 1: Check if THIS USER already processed this request
// Scope by BOTH user_id AND idempotency_key
$existingOrder = 命令 :: 在哪里 'user_id' , $userId)
-> 在哪里 'idempotency_key' , $idempotencyKey)
-> 第一的 ();
如果 ($existingOrder) {
\Log :: 信息 'Idempotent request detected' ,[
'idempotency_key' => $idempotencyKey,
'order_id' => $existingOrder -> id,
'user_id' => $userId
]);
// Return the existing order without creating a duplicate
返回 $existingOrder;
}
// STEP 2: This is a new request, proceed with checkout
$product = 产品 :: 查找或失败 ($productId);
$amount = $product -> 价格 * $quantity;
// Debit wallet atomically
$walletResult = Wallet :: 生的 功能 ($collection) 使用 ($userId, $amount) {
返回 $集合 -> updateOne
[
'user_id' => $userId,
'balance' => [ '$gte' => $amount]
],
[
'$inc' => [ 'balance' => - $amount]
]
(英文):
});
如果 ($walletResult -> getModifiedCount () === 0 ){
新的 \Exception 'Insufficient funds' (英文):
}
// Decrease inventory atomically
$inventoryResult = 产品 :: 生的 功能 ($collection) 使用 ($productId, $quantity) {
返回 $集合 -> updateOne
[
'_id' => 新的 ObjectId ($productId),
'stock' => [ '$gte' => $quantity]
],
[
'$inc' => [ 'stock' => - $quantity]
]
(英文):
});
如果 ($inventoryResult -> getModifiedCount () === 0 ){
新的 \Exception 'Insufficient stock' (英文):
}
// Create order WITH idempotency key
返回 命令 :: 创造 ([
'idempotency_key' => $idempotencyKey,
'user_id' => $userId,
'product_id' => $productId,
'quantity' => $quantity,
'amount' => $amount,
'status' => 'completed' ,
'created_at' => 现在 ()
]);
});
返回 回复 () -> json ([
'success' => 真的 ,
'order' => $order,
'is_duplicate' => $订单 -> 最近创建
]);
} 抓住 \Exception $e) {
返回 回复 () -> json ([
'error' => $e -> 获取消息 ()
], 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 = 命令 :: 在哪里 'idempotency_key' , $idempotencyKey) -> 第一的 ();
// CORRECT - Scope by both user_id and idempotency_key
$existingOrder = 命令 :: 在哪里 'user_id' , $userId)
-> 在哪里 'idempotency_key' , $idempotencyKey)
-> 第一的 ();
如果 ($existingOrder) {
返回 $existingOrder; // Early return, skip all processing
}

3. Store the key with the order:

命令 :: 创造 ([
'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:

命名空间 Tests\Feature ;
使用 Tests\TestCase ;
使用 App\Models\Wallet ;
使用 App\Models\Product ;
使用 App\Models\Order ;
使用 Illuminate\Support\Str ;
班级 IdempotencyTest 延伸 测试用例
{
受保护 功能 设置 () : 空白
{
parent:: 设置 ();
Wallet :: 截短 ();
产品 :: 截短 ();
命令 :: 截短 ();
}
民众 功能 test_idempotency_prevents_duplicate_charges ()
{
$product = 产品 :: 创造 ([
'姓名' => 'Laptop' ,
'price' => 1000.00 ,
'stock' => 5
]);
$wallet = Wallet :: 创造 ([
'user_id' => 'user-1' ,
'balance' => 2500.00
]);
// Generate idempotency key ONCE
$idempotencyKey = 力量 :: 唯一标识 () -> toString ();
// First request
$response1 = $this -> 帖子Json '/api/checkout' ,[
'user_id' => 'user-1' ,
'product_id' => $product -> id,
'quantity' => 1 ,
'idempotency_key' => $idempotencyKey
]);
$response1 -> 断言状态 200 (英文):
$response1 -> assertJsonPath 'is_duplicate' , 错误的 (英文):
// Simulate retry with SAME idempotency key
$response2 = $this -> 帖子Json '/api/checkout' ,[
'user_id' => 'user-1' ,
'product_id' => $product -> id,
'quantity' => 1 ,
'idempotency_key' => $idempotencyKey // ← Same key!
]);
$response2 -> 断言状态 200 (英文):
$response2 -> assertJsonPath 'is_duplicate' , 真的 (英文):
// Both responses should return the SAME order
$this -> 断言等于
$response1 -> json 'order.id' ),
$response2 -> json 'order.id' ),
'Should return the same order for duplicate requests'
(英文):
// Verify data integrity
$wallet -> 刷新 ();
$product -> 刷新 ();
$orderCount = 命令 :: 在哪里 'user_id' , 'user-1' -> 数数 ();
$this -> 断言等于 1500.00 , $wallet -> balance, 'Charged only once' (英文):
$this -> 断言等于 4 , $product -> stock, 'Decremented only once' (英文):
$this -> 断言等于 1 , $orderCount, 'Only one order created' (英文):
}
民众 功能 test_different_idempotency_keys_create_separate_orders ()
{
$product = 产品 :: 创造 ([
'姓名' => 'Laptop' ,
'price' => 1000.00 ,
'stock' => 5
]);
$wallet = Wallet :: 创造 ([
'user_id' => 'user-1' ,
'balance' => 3000.00
]);
// First order with first key
$key1 = 力量 :: 唯一标识 () -> toString ();
$response1 = $this -> 帖子Json '/api/checkout' ,[
'user_id' => 'user-1' ,
'product_id' => $product -> id,
'quantity' => 1 ,
'idempotency_key' => $key1
]);
// Second order with different key
$key2 = 力量 :: 唯一标识 () -> toString ();
$response2 = $this -> 帖子Json '/api/checkout' ,[
'user_id' => 'user-1' ,
'product_id' => $product -> id,
'quantity' => 1 ,
'idempotency_key' => $key2
]);
$response1 -> 断言状态 200 (英文):
$response2 -> 断言状态 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 -> 刷新 ();
$orderCount = 命令 :: 在哪里 'user_id' , 'user-1' -> 数数 ();
$this -> 断言等于 1000.00 , $wallet -> balance, 'Charged twice for two orders' (英文):
$this -> 断言等于 2 , $orderCount, 'Two orders created' (英文):
}
民众 功能 test_concurrent_requests_with_same_key_create_one_order ()
{
$product = 产品 :: 创造 ([
'姓名' => 'Laptop' ,
'price' => 1000.00 ,
'stock' => 5
]);
$wallet = Wallet :: 创造 ([
'user_id' => 'user-1' ,
'balance' => 2500.00
]);
$idempotencyKey = 力量 :: 唯一标识 () -> toString ();
// Send 5 TRULY CONCURRENT requests with the same idempotency key
// Using Laravel's HTTP client pool for parallel execution
$promises = [];
为了 ($i = 0 ; $i < 5 ; $i ++ ){
$promises[] = Http :: async () -> 邮政 网址 '/api/checkout' ), [
'user_id' => 'user-1' ,
'product_id' => $product -> id,
'quantity' => 1 ,
'idempotency_key' => $idempotencyKey
]);
}
// Execute all requests in parallel and wait for completion
$响应 = Http :: 水池 fn ($pool) => $promises);
// All should succeed (200)
foreach ($responses 作为 $response) {
$this -> 断言等于 200 , $response -> 地位 (), 'All requests should succeed' (英文):
}
// All should return the same order ID
$orderIds = 收集 ($responses)
-> 地图 fn ($r) => $r -> json 'order.id' ))
-> 独特的 ();
$this -> 断言等于 1 , $orderIds -> 数数 (),
'All concurrent requests should return the same order' (英文):
// Only one charge
$wallet -> 刷新 ();
$orderCount = 命令 :: 在哪里 'user_id' , 'user-1' -> 数数 ();
$this -> 断言等于 1500.00 , $wallet -> balance, 'Charged only once' (英文):
$this -> 断言等于 1 , $orderCount, 'Only one order exists' (英文):
}
}

Run the tests:

php 工匠 测试 --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:

< 模板 >
< div >
< 按钮
@ click = handleCheckout
: disabled = isProcessing
>
{{ isProcessing 'Processing...' : 'Buy Now' }}
</ 按钮 >
< div v-if = 错误 班级 = "error" >
{{ error }}
< 按钮 @ click = retryCheckout >Retry</ 按钮 >
</ div >
</ div >
</ 模板 >
< 脚本 >
出口 默认 {
数据 (){
返回 {
isProcessing: 错误的 ,
error: 无效的 ,
idempotencyKey: 无效的
}
},
methods: {
handleCheckout (){
// Generate idempotency key ONCE when user clicks
.idempotencyKey = crypto. randomUUID ();
performCheckout ();
},
async performCheckout (){
.isProcessing = 真的 ;
.error = 无效的 ;
尝试 {
常量 回复 = 等待 axios. 邮政 '/api/checkout' , {
user_id: .userId,
product_id: .productId,
quantity: 1 ,
idempotency_key: .idempotencyKey // ← Same key for retries
});
$emit 'checkout-success' , response.data.order);
} 抓住 (error) {
如果 (error.code === 'ECONNABORTED' || error.response?.status === 500 ){
// Network timeout or server error - safe to retry
.error = 'Network error. Please retry.' ;
} 别的 {
// Business error (insufficient funds, out of stock)
.error = error.response?.data?.error || 'Checkout failed' ;
.idempotencyKey = 无效的 ; // Reset key for new attempt
}
} 最后 {
.isProcessing = 错误的 ;
}
},
retryCheckout (){
// Retry with the SAME idempotency key
performCheckout ();
}
}
}
</ 脚本 >

React example:

进口 { useState } 'react' ;
进口 axios 'axios' ;
功能 CheckoutButton ({ 用户身份 , productId }) {
常量 [ isProcessing , setIsProcessing ] = useState 错误的 (英文):
常量 [ 错误 , setError ] = useState 无效的 (英文):
常量 [ idempotencyKey , setIdempotencyKey ] = useState 无效的 (英文):
常量 handleCheckout = () => {
// Generate idempotency key when user clicks
常量 钥匙 = crypto. randomUUID ();
setIdempotencyKey (key);
performCheckout (key);
};
常量 performCheckout = async 钥匙 => {
setIsProcessing 真的 (英文):
setError 无效的 (英文):
尝试 {
常量 回复 = 等待 axios. 邮政 '/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);
} 抓住 (err) {
如果 (err.code === 'ECONNABORTED' || err.response?.status === 500 ){
// Network error - safe to retry with same key
setError 'Network error. Click retry.' (英文):
} 别的 {
// Business error - need new key for new attempt
setError (err.response?.data?.error || 'Checkout failed' (英文):
setIdempotencyKey 无效的 (英文):
}
} 最后 {
setIsProcessing 错误的 (英文):
}
};
常量 retryCheckout = () => {
// Retry with the SAME idempotency key
performCheckout (idempotencyKey);
};
返回
< div >
< 按钮
onClick = {handleCheckout}
disabled = {isProcessing}
>
{isProcessing 'Processing...' : 'Buy Now' }
</ 按钮 >
{error &&
< div className = "error" >
{error}
{idempotencyKey &&
< 按钮 onClick = {retryCheckout}>Retry</ 按钮 >
)}
</ div >
)}
</ div >
(英文):
}

Plain JavaScript example:

班级 CheckoutHandler {
constructor 用户身份 , productId ){
.userId = userId;
.productId = productId;
.idempotencyKey = 无效的 ;
}
async handleCheckout (){
// Generate key when user initiates
.idempotencyKey = crypto. randomUUID ();
返回 performCheckout ();
}
async performCheckout (){
尝试 {
常量 回复 = 等待 拿来 '/api/checkout' , {
方法: 'POST' ,
headers: {
‘内容类型’ : ‘应用程序/json’
},
body: JSON stringify ({
user_id: .userId,
product_id: .productId,
quantity: 1 ,
idempotency_key: .idempotencyKey
})
});
如果 response.ok) {
常量 错误 = 等待 回复。 json ();
新的 Error (error.message);
}
常量 数据 = 等待 回复。 json ();
返回 data.order;
} 抓住 (error) {
如果 (error.name === 'TypeError' || error.message. includes 'network' )){
// Network error - can retry with same key
console. 日志 'Network error, safe to retry' (英文):
error;
} 别的 {
// Business error - need new key
.idempotencyKey = 无效的 ;
error;
}
}
}
async 重试 (){
// Retry with same idempotency key
如果 .idempotencyKey) {
新的 Error 'No idempotency key to retry' (英文):
}
返回 performCheckout ();
}
}
// Usage
常量 checkout = 新的 CheckoutHandler 'user-123' , 'product-456' (英文):
document. getElementById 'checkout-btn' )。 addEventListener 'click' , async () => {
尝试 {
常量 命令 = 等待 checkout. handleCheckout ();
showSuccess (order);
} 抓住 (error) {
showError (error.message);
// Show retry button for network errors
如果 (checkout.idempotencyKey) {
document. getElementById 'retry-btn' ).style.display = 'block' ;
}
}
});
document. getElementById 'retry-btn' )。 addEventListener 'click' , async () => {
尝试 {
常量 命令 = 等待 checkout. 重试 ();
showSuccess (order);
} 抓住 (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)

命名空间 App\Console\Commands ;
使用 Illuminate\Console\Command ;
使用 App\Models\Order ;
班级 CleanupOldIdempotencyKeys 延伸 命令
{
受保护 $signature = 'idempotency:cleanup' ;
受保护 $description = 'Remove idempotency keys older than 30 days' ;
民众 功能 处理 ()
{
$thirtyDaysAgo = 现在 () -> 子日 30 (英文):
// Option 1: Remove the key field from old orders
命令 :: 在哪里 'created_at' , '<' , $thirtyDaysAgo)
-> 更新 ([ 'idempotency_key' => 无效的 ]);
// Option 2: Use MongoDB TTL index (set at collection level)
// This automatically removes old orders entirely
$db = 数据库 :: 联系 'mongodb' -> getMongoDB ();
$db -> 订单 -> createIndex
[ 'created_at' => 1 ],
[ 'expireAfterSeconds' => 2592000 ] // 30 days
(英文):
$this -> 信息 'Cleaned up old idempotency keys' (英文):
}
}

Schedule it in app/Console/Kernel.php :

受保护 功能 日程 日程 $schedule)
{
$时间表 -> 命令 'idempotency:cleanup'
-> 日常的 ()
-> '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:

民众 功能 checkout 要求 $request)
{
// ... validation ...
$订单 = 数据库 :: 联系 'mongodb' -> 交易 功能 () 使用 ($validated) {
$idempotencyKey = $validated[ 'idempotency_key' ];
$existingOrder = 命令 :: 在哪里 'idempotency_key' , $idempotencyKey) -> 第一的 ();
如果 ($existingOrder) {
// Log duplicate attempt
\Log :: 信息 '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' => 现在 () -> diffInSeconds ($existingOrder -> created_at)
]);
返回 $existingOrder;
}
// ... proceed with checkout ...
});
返回 回复 () -> json ([
'order' => $订单
]);
}

Create a dashboard query to monitor duplicate rates:

// In a monitoring controller or command
民众 功能 getDuplicateCheckoutStats ()
{
$logs = 数据库 :: 联系 'mongodb'
-> 收藏 'logs'
-> 在哪里 'message' , 'Duplicate checkout attempt prevented'
-> 在哪里 'created_at' , '>=' , 现在 () -> subHours 24 ))
-> 得到 ();
返回 [
'total_duplicates_24h' => $logs -> 数数 (),
'avg_retry_delay' => $logs -> avg 'time_since_original' ),
'users_with_retries' => $logs -> 采摘 'user_id' -> 独特的 () -> 数数 ()
];
}

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:

民众 功能 refund 要求 $request)
{
$已验证 = $请求 -> 证实 ([
'order_id' => 'required|string' ,
'amount' => 'required|numeric' ,
'idempotency_key' => 'required|string|size:36'
]);
数据库 :: 联系 'mongodb' -> 交易 功能 () 使用 ($validated) {
// Check for existing refund with this key
$existingRefund = Refund :: 在哪里 'idempotency_key' , $validated[ 'idempotency_key' ])
-> 第一的 ();
如果 ($existingRefund) {
返回 $existingRefund;
}
// Process refund...
});
}

Subscription charges:

民众 功能 chargeSubscription 要求 $request)
{
$已验证 = $请求 -> 证实 ([
'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:

民众 功能 transfer 要求 $request)
{
$已验证 = $请求 -> 证实 ([
'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

常见问题解答

How do I generate idempotency keys in Laravel?

使用 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 :: 生的 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:

数据库 :: 联系 'mongodb' -> 交易 功能 (){
// Wallet debit, inventory update, order creation - all atomic
});

Part 3 made our operations safe to retry using idempotency keys:

$existing = 命令 :: 在哪里 'idempotency_key' , $key) -> 第一的 ();
如果 ($existing) 返回 $existing;

Together, these techniques create a system that's:

  • Race-condition free (atomic operations)
  • Consistent (transactions)
  • 可靠的 (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 时事通讯

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

图像
Acquaint Softtech

Hire Laravel developers with AI expertise at $20/hr. Get started in 48 hours.

Visit Acquaint Softtech
Tinkerwell 徽标

廷克威尔

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

廷克威尔
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 云

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

Laravel 云
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

转移

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

转移
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 Starter Kits Now Include Toast Notifications image

Laravel Starter Kits Now Include Toast Notifications

阅读文章
Ship AI with Laravel: Stop Your AI Agent from Guessing image

Ship AI with Laravel: Stop Your AI Agent from Guessing

阅读文章
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

阅读文章
Making Laravel MongoDB Operations Idempotent: Safe Retries for Financial Transactions image

Making Laravel MongoDB Operations Idempotent: Safe Retries for Financial Transactions

阅读文章
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

阅读文章
Pretty PHP Info: A Modern Replacement for `phpinfo()` image

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

阅读文章