Idempotent HTTP responses
composer require langsys/laravel-request-query-cache 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 /paymentsIdempotency-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)How a request is handled
Section titled “How a request is handled”- Only
POST/PUT/PATCHare guarded (configurable). Everything else passes through. - No key present →
400if the route requires it, otherwise passes through. - Key seen before, same request → the stored response is replayed.
- Key seen before, different request body →
422(the key was reused for something else — a client bug you want surfaced, not silently mishandled). - 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 byscope_attribute(defaultapi_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').
Per-route overrides
Section titled “Per-route overrides”Override ttl, required, and scope inline — idempotent:{ttl},{required},{scope}:
// 24h window, header mandatory, scoped per authenticated userRoute::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');Configuration
Section titled “Configuration”Defaults work out of the box. To customize, publish the config:
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 disableTTL 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.
Requirements & caveats
Section titled “Requirements & caveats”- The cache store must support atomic locks —
redis,memcached,dynamodb,database,file, orarray. Set a specific store via thestoreconfig key if your default doesn’t. (If the store can’t lock, the middleware still replays but loses the concurrent-duplicate409guarantee.) 5xxresponses are never stored — a transient server error must re-execute on retry, not replay forever.2xx–4xxresponses 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.