Dealing with duplicate HTTP requests is one of the most critical challenges when designing production APIs. In distributed systems, networks are inherently unreliable: mobile devices switch cell towers, timeouts trigger automated client retries, and users impatiently double-click “Submit Order” buttons.
Without idempotency safeguards, these duplicate requests can cause severe data corruption—such as double-charging a customer’s credit card or creating duplicate inventory reservations.
In this guide, you will build a production-ready, robust Idempotent API in Laravel from scratch. You will learn how the Idempotency-Key header standard works, create a database migration and Eloquent model for idempotency records, write a reusable Laravel middleware that leverages atomic locking, and verify the entire lifecycle with automated feature tests.
1. What is an Idempotent API and Why Do We Need It?
An HTTP method is considered idempotent if making identical requests repeatedly produces the exact same server-side outcome as making a single request.
According to the HTTP specification (RFC 9110):
GET,HEAD,OPTIONS, andTRACEare safe and idempotent by definition.PUTandDELETEare idempotent because replacing an existing resource or deleting it multiple times yields the same final resource state.POSTis not idempotent. EachPOST /api/ordersorPOST /api/paymentstypically creates a brand-new database record and triggers external side effects (like charging Stripe or sending an email notification).
Consider what happens in a standard payment flow without idempotency:

The client timed out waiting for the acknowledgment even though the server successfully charged the card. When the client retried, the server happily charged the card a second time.
An idempotent API solves this by having the client attach a unique identifier (the Idempotency Key) to the request. The server remembers this key. If it sees the same key again, it does not re-run the payment; instead, it immediately returns the saved response from the first execution.
2. Architecture of Idempotent APIs: The Idempotency-Key Header
The standard industry approach (pioneered by Stripe and standardized under IETF draft-ietf-httpapi-idempotency-key-header) follows these principles:
- Client Responsibility: The client generates a unique UUID (v4) for each logical operation and sends it in the
Idempotency-Key(orX-Idempotency-Key) header. - Request Fingerprinting: The server hashes the request method, URL, and payload (
sha256(url + body)). If a client sends the same idempotency key with a different body, the server must reject it with422 Unprocessable Contentto prevent accidental key collisions. - Atomic Locking & State Machine:
pending: The first request is currently executing. Any subsequent request with the same key arriving simultaneously receives409 Conflict.processed: The request finished successfully. The server stored the HTTP status code, headers, and body. Any subsequent request with the same key immediately receives this stored response without executing controller code.
- Time To Live (TTL): Idempotency records are cached or persisted for a designated period (e.g., 24 hours), after which they expire.
3. Creating the Database Migration and Eloquent Model
Let’s create the database migration to store idempotency records. Run the following artisan command:
php artisan make:migration create_idempotency_keys_table
Open the generated migration in database/migrations/ and define the schema:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('idempotency_keys', function (Blueprint $table) {
$table->id();
$table->string('key', 128)->index();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('request_path');
$table->string('request_hash', 64); // SHA-256 fingerprint
$table->string('status', 32)->default('pending'); // pending, processed, failed
$table->integer('response_code')->nullable();
$table->json('response_headers')->nullable();
$table->longText('response_body')->nullable();
$table->timestamp('expires_at')->index();
$table->timestamps();
$table->unique(['key', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('idempotency_keys');
}
};
Run the migration:
php artisan migrate
Now create the Eloquent model:
php artisan make:model IdempotencyKey
Define the model in app/Models/IdempotencyKey.php:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class IdempotencyKey extends Model
{
protected $fillable = [
'key',
'user_id',
'request_path',
'request_hash',
'status',
'response_code',
'response_headers',
'response_body',
'expires_at',
];
protected $casts = [
'response_headers' => 'array',
'response_code' => 'integer',
'expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isPending(): bool
{
return $this->status === 'pending';
}
public function isProcessed(): bool
{
return $this->status === 'processed';
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
}
4. Implementing the Idempotency Middleware in Laravel
Next, build the core middleware that intercepts requests, checks for duplicates, acquires locks, and caches responses.
Generate the middleware:
php artisan make:middleware EnsureIdempotency
Implement the middleware logic in app/Http/Middleware/EnsureIdempotency.php:
<?php
namespace App\Http\Middleware;
use App\Models\IdempotencyKey;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class EnsureIdempotency
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
// Only enforce idempotency on mutating operations
if (! in_array($request->method(), ['POST', 'PATCH'], true)) {
return $next($request);
}
$idempotencyKey = $request->header('Idempotency-Key') ?? $request->header('X-Idempotency-Key');
// If no idempotency key is passed, allow normal execution
if (blank($idempotencyKey)) {
return $next($request);
}
$userId = $request->user()?->id;
$requestHash = hash('sha256', $request->method() . '|' . $request->path() . '|' . json_encode($request->all()));
// Atomic lock key in cache to prevent concurrent race conditions
$lockKey = "idempotency_lock:{$userId}:{$idempotencyKey}";
$lock = Cache::lock($lockKey, 15); // 15-second lock timeout
if (! $lock->get()) {
return response()->json([
'error' => 'Conflict',
'message' => 'An identical request is currently being processed. Please retry shortly.',
], Response::HTTP_CONFLICT);
}
try {
$record = IdempotencyKey::where('key', $idempotencyKey)
->where('user_id', $userId)
->first();
if ($record) {
// If the key has expired, clean it up and treat as new
if ($record->isExpired()) {
$record->delete();
} else {
// Detect payload tampering: same key used with different request body
if ($record->request_hash !== $requestHash) {
return response()->json([
'error' => 'Unprocessable Entity',
'message' => 'The provided Idempotency-Key was previously used with a different request payload.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
// Request is currently in flight from another thread/process
if ($record->isPending()) {
return response()->json([
'error' => 'Conflict',
'message' => 'An operation using this Idempotency-Key is currently in progress.',
], Response::HTTP_CONFLICT);
}
// Return the stored response verbatim
return $this->reconstructResponse($record);
}
}
// Create initial record in pending state
$record = IdempotencyKey::create([
'key' => $idempotencyKey,
'user_id' => $userId,
'request_path' => $request->path(),
'request_hash' => $requestHash,
'status' => 'pending',
'expires_at' => now()->addHours(24),
]);
// Execute the controller action
$response = $next($request);
// Store the completed response for future replaying
if ($response->isSuccessful()) {
$record->update([
'status' => 'processed',
'response_code' => $response->getStatusCode(),
'response_headers' => $this->filterHeaders($response),
'response_body' => $response->getContent(),
]);
} else {
// If the controller threw an application error (e.g., 400 Bad Request),
// remove the record so the client can fix their payload and retry.
$record->delete();
}
// Append header indicating the response is original
$response->headers->set('Idempotency-Replay', 'false');
return $response;
} finally {
$lock->release();
}
}
/**
* Reconstitute an HTTP response from the stored database record.
*/
protected function reconstructResponse(IdempotencyKey $record): Response
{
$response = response(
$record->response_body,
$record->response_code,
$record->response_headers ?? []
);
// Notify client that this response was replayed from cache
$response->headers->set('Idempotency-Replay', 'true');
return $response;
}
/**
* Filter response headers to store only relevant application headers.
*/
protected function filterHeaders(Response $response): array
{
$headers = [];
foreach (['Content-Type', 'X-Custom-Header'] as $name) {
if ($response->headers->has($name)) {
$headers[$name] = $response->headers->get($name);
}
}
return $headers;
}
}
In Laravel 11, register this middleware in bootstrap/app.php:
->withMiddleware(function (Middleware \$middleware) {
\$middleware->alias([
'idempotent' => \App\Http\Middleware\EnsureIdempotency::class,
]);
})
5. Building an Example Controller (Payment Processing)
Now let’s build an endpoint to demonstrate idempotency in action. Create a controller:
php artisan make:controller Api/PaymentController
Implement the payment method in app/Http/Controllers/Api/PaymentController.php:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class PaymentController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'amount' => ['required', 'numeric', 'min:1'],
'currency' => ['required', 'string', 'size:3'],
]);
// Simulate external payment gateway processing delay
usleep(100000); // 100ms
$transactionId = 'tx_' . Str::random(16);
return response()->json([
'status' => 'success',
'transaction_id' => $transactionId,
'amount' => $validated['amount'],
'currency' => strtoupper($validated['currency']),
'processed_at' => now()->toISOString(),
], 201);
}
}
Register the route in routes/api.php with the middleware applied:
use App\Http\Controllers\Api\PaymentController;
Route::post('/payments', [PaymentController::class, 'store'])
->middleware(['auth:sanctum', 'idempotent']);
6. Writing Feature Tests in Laravel
Let’s write a comprehensive feature test to verify that duplicate requests return identical responses and don’t re-execute logic.
Generate the test:
php artisan make:test IdempotentApiTest
Open tests/Feature/IdempotentApiTest.php:
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class IdempotentApiTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
}
public function test_initial_request_executes_and_returns_created_status(): void
{
$key = (string) Str::uuid();
$response = $this->actingAs($this->user)
->withHeaders(['Idempotency-Key' => $key])
->postJson('/api/payments', [
'amount' => 150.00,
'currency' => 'USD',
]);
$response->assertStatus(201)
->assertHeader('Idempotency-Replay', 'false')
->assertJsonPath('status', 'success');
$this->assertDatabaseHas('idempotency_keys', [
'key' => $key,
'status' => 'processed',
'user_id' => $this->user->id,
]);
}
public function test_duplicate_request_returns_cached_response_without_reprocessing(): void
{
$key = (string) Str::uuid();
$payload = ['amount' => 99.50, 'currency' => 'EUR'];
// First attempt
$firstResponse = $this->actingAs($this->user)
->withHeaders(['Idempotency-Key' => $key])
->postJson('/api/payments', $payload);
$firstResponse->assertStatus(201);
$firstTransactionId = $firstResponse->json('transaction_id');
// Second attempt with exact same key and payload
$secondResponse = $this->actingAs($this->user)
->withHeaders(['Idempotency-Key' => $key])
->postJson('/api/payments', $payload);
$secondResponse->assertStatus(201)
->assertHeader('Idempotency-Replay', 'true')
->assertJsonPath('transaction_id', $firstTransactionId); // Identical transaction ID!
}
public function test_reusing_same_key_with_different_payload_returns_422_unprocessable(): void
{
$key = (string) Str::uuid();
// Initial request with $50
$this->actingAs($this->user)
->withHeaders(['Idempotency-Key' => $key])
->postJson('/api/payments', ['amount' => 50.00, 'currency' => 'USD'])
->assertStatus(201);
// Reusing same key with $100
$tamperedResponse = $this->actingAs($this->user)
->withHeaders(['Idempotency-Key' => $key])
->postJson('/api/payments', ['amount' => 100.00, 'currency' => 'USD']);
$tamperedResponse->assertStatus(422)
->assertJsonPath('error', 'Unprocessable Entity');
}
}
Run the tests using Artisan:
php artisan test --filter=IdempotentApiTest
All assertions will pass cleanly.
7. Production Best Practices: Database vs. Redis Caching
When scaling your idempotent API to high-traffic workloads, keep the following architectural guidelines in mind:
Storage Tiering:
For high write volumes, store idempotency records in Redis with an automatic TTL (EXPIRE key 86400). Redis provides sub-millisecond lookups and eliminates the need for manual pruning jobs.
For transactional financial systems where audit compliance is mandatory, store records in a relational database (PostgreSQL or MySQL) so that the idempotency record and the entity record can participate in the same database transaction.
Pruning Scheduled Job:
If using the database approach, schedule a cleanup job in routes/console.php:
Schedule::call(function () {
\App\Models\IdempotencyKey::where('expires_at', '<', now())->delete();
})->daily();
Atomic Distributed Locks:
Always wrap concurrent request resolution in Laravel’s Cache::lock() with an appropriate driver (Redis or Memcached). This prevents race conditions when a distributed client fires parallel requests at identical milliseconds.
By combining the Idempotency-Key header, request fingerprinting, atomic locks, and response replaying, your Laravel API will handle network retries gracefully and provide a resilient foundation for your users.
Frequently Asked Questions
What is an Idempotent API and why is it necessary?
An idempotent API guarantees that making multiple identical requests has the same effect as making a single request. This prevents duplicate charges, duplicate order creation, and data inconsistencies caused by network timeouts or automated client retries.
Does Laravel have an official built-in idempotency package?
No, Laravel does not include a built-in idempotency package out of the box. You can implement it using custom middleware with database records and atomic cache locks (as shown in this tutorial), or use trusted community packages such as spatie/laravel-idempotency.
How should the client generate the Idempotency-Key?
Clients should generate a unique UUID version 4 (or random cryptographic string) for each distinct operation, sending it in the Idempotency-Key or X-Idempotency-Key HTTP request header.
What happens if a client reuses an idempotency key with a different payload?
The server should fingerprint the request by hashing the URL and request body. If a matching key is found with a different hash, the API should reject the request with HTTP 422 Unprocessable Entity to prevent payload tampering.
How are race conditions prevented when duplicate requests arrive at the exact same millisecond?
Using atomic distributed locks like Laravel’s Cache::lock() ensures that only one request can acquire the processing lock at a time. The secondary concurrent request receives an HTTP 409 Conflict status or waits for the lock to release.
