When building payment systems, one of the most critical requirements isn't just processing payments; it's ensuring that the same payment is never processed twice.
Duplicate payments can occur more often than you might think:
- A user clicks the Pay button multiple times.
- The user's internet connection is slow, so they refresh the page.
- A mobile application automatically retries a failed request.
- A reverse proxy or load balancer retries a request after a timeout.
Without proper safeguards, your application could create multiple orders or charge the customer's payment method more than once.
This is where Idempotency Keys come into play.
What Is an Idempotency Key?
An Idempotency Key is a unique identifier generated by the client for a request.
Every payment request carries this key, typically in an HTTP header:
POST /api/payments
Idempotency-Key: 7c98b49d-5d6e-4b52-bc31-7dfb67c76d77
The server stores the key when processing the request.
If another request arrives with the same key, the server does not process the payment again. Instead, it returns the result of the original request.
In simple terms:
Same request + Same idempotency key = Same result
The Problem Without Idempotency
Consider the following controller:
public function store(PaymentRequest $request)
{
$order = Order::create($request->validated());
PaymentService::charge($order);
return response()->json($order);
}
Now imagine this sequence:
Customer clicks "Pay"
↓
Network is slow
↓
Customer clicks "Pay" again
↓
Two HTTP requests reach the server
↓
Two orders are created
↓
Customer is charged twice
This is one of the most common production issues in payment systems.
A Better Approach
Instead of processing every request blindly, the server first checks whether the idempotency key has already been processed.
A simplified implementation might look like this:
public function store(PaymentRequest $request)
{
$key = $request->header('Idempotency-Key');
if (IdempotencyKey::where('key', $key)->exists()) {
return response()->json([
'message' => 'Request already processed.'
]);
}
IdempotencyKey::create([
'key' => $key,
]);
$order = Order::create($request->validated());
PaymentService::charge($order);
return response()->json($order);
}
Now the flow becomes:
Customer clicks "Pay" twice
↓
Both requests contain the same Idempotency-Key
↓
First request processes successfully
↓
Second request is recognized as a duplicate
↓
No duplicate order
No duplicate payment
A Production-Ready Implementation
The previous example demonstrates the concept, but it isn't safe enough for a real production environment.
Imagine two identical requests arriving at the same time.
Both requests could execute this line before either has inserted the key:
IdempotencyKey::where('key', $key)->exists()
Both requests would receive false.
Both would continue processing.
You would still end up with duplicate payments.
Instead, create a unique index on the key column:
Schema::create('idempotency_keys', function (Blueprint $table) {
$table->id();
$table->uuid('key')->unique();
$table->timestamps();
});
Now the database guarantees uniqueness.
If two requests attempt to store the same key simultaneously, only one succeeds.
The other request can safely return the response from the original request.
This approach eliminates race conditions that application-level checks alone cannot prevent.
Should You Store Only the Key?
Not necessarily.
Many production systems store additional information alongside the key, such as:
- Request payload
- Response body
- HTTP status code
- User ID
- Creation timestamp
- Expiration timestamp
This allows the application to return the same response for duplicate requests without executing the business logic again.
Where Should You Use Idempotency Keys?
Although commonly associated with payments, the pattern is useful anywhere duplicate requests can cause problems.
Examples include:
- Payment processing
- Order creation
- Subscription renewals
- Wallet transactions
- Refund requests
- Inventory reservations
- Third-party webhook processing
Any operation that must happen exactly once is a good candidate.
Best Practices
When implementing idempotency keys, keep these practices in mind:
- Generate a unique key on the client for each logical operation.
- Enforce uniqueness with a database constraint rather than relying solely on application logic.
- Wrap the idempotency record and the business operation in a database transaction when appropriate.
- Store the original response so duplicate requests can receive the same result.
- Expire old keys after a reasonable period to prevent unnecessary database growth.
Final Thoughts
Building production-ready applications isn't just about writing clean controllers or optimizing database queries.
It's about anticipating what can go wrong in real-world environments.
Network retries happen.
Users double-click buttons.
Mobile applications automatically retry failed requests.
Without idempotency, these normal events can turn into duplicate orders, duplicate charges, and unhappy customers.
Implementing idempotency keys is a relatively small change, but it dramatically improves the reliability of your payment flows and other critical business operations.
In production systems, correctness is just as important as performance.
Laravel in Production is a series where I share practical techniques, architectural patterns, and lessons for building reliable, scalable Laravel applications that can handle real-world production challenges.