Making Laravel MongoDB Operations Idempotent: Safe Retries for Financial Transactions
Published on 经过 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
介绍
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 重试 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: 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?
一个 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:
命名空间
应用程序\模型
;使用
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 keyconsole.
日志
(
'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:
- 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)
命名空间
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





