Skip to content
Nav

Idempotent HTTP responses

composer require langsys/laravel-request-query-cache
Packagist version Monthly downloads License PHP 8.2+ Laravel 10 / 11 / 12

State-changing endpoints (payments, orders, sign-ups) get retried — by impatient users, flaky networks, and queue workers. The idempotent middleware makes those retries safe: the client sends a unique Idempotency-Key header, and any repeat of that key replays the original response instead of running the route twice.

use App\Http\Controllers\PaymentController;
Route::post('/payments', [PaymentController::class, 'store'])
->middleware('idempotent');
POST /payments
Idempotency-Key: 7f3c… # client-generated, unique per logical operation
→ first call: runs the controller, stores the response
→ same key: replays the stored response (+ Idempotency-Replayed: true)
  1. Only POST/PUT/PATCH are guarded (configurable). Everything else passes through.
  2. No key present → 400 if the route requires it, otherwise passes through.
  3. Key seen before, same request → the stored response is replayed.
  4. Key seen before, different request body → 422 (the key was reused for something else — a client bug you want surfaced, not silently mishandled).
  5. Key currently in flight (a concurrent duplicate) → 409 + Retry-After: 1. An atomic lock guarantees the route body runs at most once even under a simultaneous double-submit.

A request’s identity (its fingerprint) is the HTTP method + route + path parameters + query string + body. Body field order doesn’t matter — {"a":1,"b":2} and {"b":2,"a":1} are the same request. Reusing one key across different path parameters (e.g. /projects/A vs /projects/B) is treated as misuse and returns 422. The key is namespaced by scope so two callers can use the same key without colliding.

Scopes — user | ip | global | apikey:

  • user — per authenticated user; falls back to the request IP when there is no session user.
  • ip — per client IP.
  • global — one key space shared by everyone.
  • apikey — per tenant for API-key auth (where there is no session user). It reads the request attribute named by scope_attribute (default api_key_id), which your auth middleware sets, and falls back to user/IP when that attribute is absent:
// in your API-key auth middleware, before the idempotent middleware runs
$request->attributes->set('api_key_id', $apiKey->id);

If you’re using API Keys, the authenticated key is already on $request->attributes->get('api_key').

Override ttl, required, and scope inline — idempotent:{ttl},{required},{scope}:

// 24h window, header mandatory, scoped per authenticated user
Route::post('/payments', [PaymentController::class, 'store'])->middleware('idempotent:86400,true,user');
// 5-minute window, optional, global (one key space for everyone)
Route::post('/webhooks/stripe', [WebhookController::class, 'stripe'])->middleware('idempotent:300,false,global');
// 1h window, optional, per API-key tenant (SDK endpoints with no session user)
Route::post('/translate', [TranslateController::class, 'store'])->middleware('idempotent:3600,false,apikey');

Defaults work out of the box. To customize, publish the config:

Terminal window
php artisan vendor:publish --tag=request-query-cache-config
// config/request-query-cache.php → 'idempotency'
'enabled' => true,
'store' => null, // null = default cache store
'header' => 'Idempotency-Key',
'ttl' => 86400, // seconds a response stays replayable (24h)
'required' => false, // 400 when the header is missing
'scope' => 'user', // user | ip | global | apikey
'scope_attribute' => 'api_key_id', // request attribute the apikey scope reads
'lock_timeout' => 10, // seconds the in-flight lock is held
'methods' => ['POST', 'PUT', 'PATCH'],
'replay_header'=> 'Idempotency-Replayed', // null to disable

TTL guidance: default to 24h for money/order endpoints (a retry hours later must not double-charge — same window Stripe uses); drop to minutes for cheap, high-volume endpoints.

  • The cache store must support atomic locks — redis, memcached, dynamodb, database, file, or array. Set a specific store via the store config key if your default doesn’t. (If the store can’t lock, the middleware still replays but loses the concurrent-duplicate 409 guarantee.)
  • 5xx responses are never stored — a transient server error must re-execute on retry, not replay forever. 2xx–4xx responses are stored.
  • Body-sensitive by design — reusing a key with a changed payload is a 422, not a silent overwrite. That’s the safety guarantee.
  • Streamed/binary (non-string-body) responses are passed through unstored.

The middleware also exposes $request->attributes->get('idempotent') (bool) and 'idempotency-key' to downstream code.

Feedback