Preventing Duplicate API Requests with Idempotency in Laravel
Imagine a customer clicks the Submit button to create a delivery request. The server processes the request, saves it to the database, and sends a response. However, the customer's internet connection drops before the response arrives. The frontend cannot tell whether the operation succeeded, so it retries the request.
If the API creates another delivery request on every attempt, the customer may end up with two requests and potentially two charges. Similar problems occur with payment processing, order creation, booking systems, wallet transactions, and external API integrations.
Idempotency is a technique for making repeated requests safe. In this guide, we will explain how it works and implement a practical approach in Laravel.
What Is Idempotency?
An operation is idempotent when repeating it with the same intended input does not produce additional effects after the first successful execution.
For example, a client sends a request to create a delivery and includes an idempotency key. If the client retries using that same key, the API should return the result of the original operation rather than create another delivery.
Without idempotency, two identical-looking HTTP requests may create two records. With a properly implemented idempotency mechanism, the server recognizes that the requests belong to the same logical operation.
Why HTTP POST Requests Can Be Dangerous to Retry
HTTP GET requests are generally designed to retrieve data without changing server state. POST requests, however, commonly create resources or trigger actions. A network timeout does not tell the client whether a POST operation failed before processing or succeeded before the connection broke.
Disabling the submit button helps prevent accidental double-clicks, but it is not enough. Requests can be retried by browsers, mobile clients, reverse proxies, job workers, or users on unreliable networks. Duplicate prevention must be enforced by the backend.
Step 1: Generate an Idempotency Key on the Client
The client should generate a unique key for each new logical operation and reuse that key when retrying the same operation. A UUID is a common choice.
For example, the client sends a request with the following header:
POST /api/delivery-requests
Idempotency-Key: 9d3d40f5-31b7-4a7a-9d2b-8b3c8c1a12e4
Content-Type: application/json
The key identifies the operation, not the customer or the request payload by itself. The server should scope the key to the authenticated user or tenant and verify that repeated requests with the same key contain equivalent input.
Do not generate a new key for every automatic retry. If a client generates a new key after every timeout, the server will treat each retry as a new operation.
Step 2: Create a Database Table for Idempotency
Redis can be useful for short-lived coordination, but a database record is often a strong foundation for operations that must survive application restarts and preserve a durable relationship between the key and the created resource.
A simplified migration might look like this:
Schema::create('idempotency_records', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('key', 100);
$table->string('request_hash', 64);
$table->string('status', 20)->default('processing');
$table->unsignedSmallInteger('response_status')->nullable();
$table->json('response_body')->nullable();
$table->timestamps();
$table->unique(['user_id', 'key']);
});
The unique index is essential. Application-level checks alone are insufficient because two concurrent requests can both check for a missing key before either inserts a record.
In a multi-tenant system, decide whether the key should be scoped by user, tenant, integration account, or another authenticated identity. The unique constraint must match that scope. If the same user can act across multiple tenants, tenant identity may also be necessary.
Step 3: Validate the Request and Calculate a Hash
The server should validate the incoming payload and create a deterministic hash from the fields that define the operation. The hash lets the server detect when a client reuses the same key for different input.
$validated = $request->validate([
'pickup_address' => ['required', 'string', 'max:255'],
'dropoff_address' => ['required', 'string', 'max:255'],
'weight_kg' => ['required', 'numeric', 'min:0.5'],
]);
$canonicalPayload = [
'pickup_address' => $validated['pickup_address'],
'dropoff_address' => $validated['dropoff_address'],
'weight_kg' => (float) $validated['weight_kg'],
];
$requestHash = hash(
'sha256',
json_encode($canonicalPayload, JSON_THROW_ON_ERROR)
);
For production code, define canonicalization carefully. Equivalent payloads should produce the same hash, and all fields that affect the operation should be included. Do not include transient fields such as request timestamps unless they are genuinely part of the operation.
Validate the idempotency key's format and length, and reject requests that omit it when the endpoint requires one.
Step 4: Handle Repeated Requests Safely
The basic workflow is:
- Authenticate the caller and validate the request.
- Look for an existing idempotency record within the correct scope.
- If a completed record exists and the request hash matches, return its stored result.
- If the key exists but the request hash differs, reject the request.
- If the operation is new, reserve the key and execute the business operation safely.
- Persist the resulting resource identifier and response details.
A simplified Laravel example for the core reservation step is:
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
$key = $request->header('Idempotency-Key');
try {
$record = DB::transaction(function () use (
$request,
$key,
$requestHash
) {
return IdempotencyRecord::create([
'user_id' => $request->user()->id,
'key' => $key,
'request_hash' => $requestHash,
'status' => 'processing',
]);
});
} catch (QueryException $exception) {
// Check whether the unique key already exists.
// Handle a duplicate-key violation specifically.
// Re-throw unrelated database errors.
}
This snippet demonstrates reserving a key, not a complete idempotency implementation. The duplicate-key handler must reload the existing record, compare its request hash, and return the stored response or a suitable in-progress response. Do not treat every database exception as a duplicate key.
Also, inserting a record with status processing and committing it separately from the business operation introduces recovery concerns. A process can crash after reservation but before completion. Production code needs a deliberate transaction and recovery strategy.
Step 5: Keep the Business Operation Atomic
For database-only operations, the idempotency record and the created resource should ideally be committed within a consistent transaction. This prevents a completed resource from being saved without the corresponding idempotency result, or a success response from being recorded before the resource exists.
For example, the transaction might create a delivery request and associate its identifier with the idempotency record:
DB::transaction(function () use ($record, $validated) {
$delivery = DeliveryRequest::create([
'user_id' => $record->user_id,
'pickup_address' => $validated['pickup_address'],
'dropoff_address' => $validated['dropoff_address'],
'weight_kg' => $validated['weight_kg'],
'status' => 'requested',
]);
$body = [
'id' => $delivery->id,
'status' => $delivery->status,
];
$record->update([
'status' => 'completed',
'response_status' => 201,
'response_body' => $body,
]);
});
This example assumes the idempotency record was safely reserved and locked or otherwise coordinated before this transaction. A complete implementation must also handle concurrent retries and the possibility that another request already owns the key.
For complex workflows, it may be useful to store a resource identifier and reconstruct the response from the current resource. If exact replay is required, persist the appropriate response status and body. Avoid storing sensitive information unnecessarily.
Step 6: Handle Concurrent Requests
Consider two identical requests arriving at almost the same time. Both can query for the key and find no record. Both then attempt to create it.
A unique database constraint ensures that only one reservation succeeds. The losing request must recognize the specific duplicate-key error and load the existing record. It should not proceed to create a second delivery.
Depending on the application, a request that encounters a record still being processed can return a conflict or retry-later response, or wait briefly for completion. Use a bounded wait and a clear client retry policy.
Locks can help coordinate work, but the database uniqueness constraint remains an important safeguard. Lock expiration, process crashes, and multiple application instances all need consideration.
Step 7: External APIs and Payment Providers
Database transactions cannot atomically commit changes to a separate payment provider or third-party API. If your operation includes a payment, shipment booking, or external notification, a local transaction alone cannot guarantee exactly-once execution across both systems.
Use the payment provider's own idempotency mechanism where available. Persist the provider's operation identifier, make retries with the same provider key when appropriate, and reconcile uncertain outcomes rather than blindly repeating a charge.
For asynchronous processing, a transactional outbox is a useful pattern. The application commits its business record and an outbox event in the same database transaction. A worker then delivers the event and records progress. The consumer should also be idempotent because queue delivery may occur more than once.
Step 8: Recover from Stuck Processing Records
An application can crash after reserving a key, leaving the record marked as processing. Define how long a processing record can remain in that state and how the system recovers it.
Useful fields include an attempt count, last error, processing timestamp, and resource identifier. A scheduled recovery process can inspect old records and determine whether to resume processing, reconcile with an external provider, or mark the operation as requiring manual review.
Do not automatically delete or retry every old processing record without checking whether the underlying operation already succeeded. This is especially important for financial transactions and external side effects.
Testing Idempotency in Laravel
Test more than a single successful request. A useful test suite should cover:
- Two sequential requests with the same key create only one resource.
- Repeated requests return the expected stored result.
- Reusing a key with a different payload is rejected.
- Two concurrent requests do not create duplicate resources.
- Failed operations do not leave permanently stuck records.
- Retries after a simulated timeout do not repeat external side effects.
- Different users or tenants cannot reuse one another's keys accidentally.
- Expired records and cleanup policies behave as intended.
Use integration tests with a real supported database when testing unique constraints, transaction behavior, and concurrency. Some behaviors may not be faithfully represented by an in-memory test database.
Common Mistakes to Avoid
- Generating a new idempotency key for every retry.
- Checking for an existing key without a unique database constraint.
- Returning success when a previous operation is still processing.
- Reusing a key for a different payload without detecting the mismatch.
- Assuming a database transaction makes an external payment atomic.
- Deleting records before clients have finished retrying.
- Ignoring stuck operations and uncertain external API outcomes.
Final Thoughts
Idempotency is a fundamental reliability technique for APIs that create resources or trigger important side effects. It protects against network retries, double submissions, and concurrent requests—but only when the client, application, database, and external integrations work together.
For a Laravel API, start with a clearly scoped idempotency key, a deterministic request hash, a database uniqueness constraint, and a safe response-replay strategy. Add transaction boundaries, concurrency handling, monitoring, and recovery procedures based on the needs of your business operation.
The goal is not simply to reject duplicate HTTP requests. It is to ensure that one logical customer action does not accidentally produce multiple business operations.