azhimov / laravel-idempotency
Idempotency-Key middleware for Laravel: safe retries for POST/PATCH APIs with request fingerprinting, concurrency locks and cache or database storage.
Requires
- php: ^8.2
- illuminate/console: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^3.8 || ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Idempotency-Key middleware for Laravel: a retried POST / PATCH is executed once, and every retry gets the original response back.
Designed with payment APIs in mind: request fingerprinting, real concurrency control, fencing when a lock expires and an optional database store that does not depend on Redis.
It makes duplicate execution much less likely; it does not make it impossible — see Limitations.
The problem
A client sends POST /payments. The payment goes through, but the connection drops before the response arrives. The client retries — and the customer is charged twice.
The standard fix (Stripe, Adyen, the IETF draft The Idempotency-Key HTTP Header Field): the client sends a unique Idempotency-Key with the request, the server performs the operation once and answers every retry with the stored response.
Installation
Requires PHP 8.2+ and Laravel 12 or 13.
composer require azhimov/laravel-idempotency
The service provider is auto-discovered. Optionally publish the config:
php artisan vendor:publish --tag=idempotency-config
Quick start
Add the idempotent middleware to the routes that create or change things. Put your auth middleware before it, so keys are scoped per user.
// routes/api.php Route::post('/payments', [PaymentController::class, 'store']) ->middleware(['auth:sanctum', 'idempotent']);
That's it. The client generates a key (a UUID is fine) per operation and reuses it for retries:
curl -i -X POST https://api.example.test/api/payments \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: 5d0b7e7a-3c55-4b2e-9a39-1f0a3c2d9e41' \ -d '{"amount":1500,"currency":"KZT"}'
HTTP/1.1 201 Created {"id":"pay_lym5etgj","amount":1500,"currency":"KZT","status":"succeeded"}
Send exactly the same request again — the controller is not called, the stored response is replayed:
HTTP/1.1 201 Created Idempotent-Replayed: true {"id":"pay_lym5etgj","amount":1500,"currency":"KZT","status":"succeeded"}
Same key, different body — rejected:
curl -i -X POST https://api.example.test/api/payments \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: 5d0b7e7a-3c55-4b2e-9a39-1f0a3c2d9e41' \ -d '{"amount":9999,"currency":"KZT"}'
HTTP/1.1 422 Unprocessable Content {"error":{"code":"idempotency_key_reused","message":"This idempotency key was already used with a different request."}}
A retry that arrives while the first request is still running:
HTTP/1.1 409 Conflict Retry-After: 29 {"error":{"code":"idempotency_request_in_progress","message":"A request with this idempotency key is still being processed. Retry later."}}
The examples above are real outputs from a fresh Laravel 13 app with this package installed.
Per-route options
->middleware('idempotent:required') // 400 without a key ->middleware('idempotent:ttl=3600') // keep responses for 1 hour on this route ->middleware('idempotent:required,ttl=600') // both ->middleware('idempotent:optional') // override a global 'required' => true
An unknown option throws — a typo like idempotent:requird must never silently disable protection.
Responses
| Situation | Status | error.code |
Handler runs? |
|---|---|---|---|
| First request with a key | handler's | — | yes |
| Same key + same request, finished | stored status, Idempotent-Replayed: true |
— | no |
| Same key + same request, still running | 409 + Retry-After |
idempotency_request_in_progress |
no |
| Same key + different request | 422 | idempotency_key_reused |
no |
No key, route required |
400 | idempotency_key_missing |
no |
| Empty, too long or non-ASCII key | 400 | idempotency_key_invalid |
no |
| No key, route optional | handler's | — | yes, without protection |
Method not in methods (e.g. GET) |
handler's | — | yes, untouched |
What gets stored and replayed:
| Handler outcome | Stored? | What a retry does |
|---|---|---|
| 2xx, 3xx, 4xx | yes, for ttl |
gets the stored response (a 303 redirect is replayed as 303) |
| 5xx | no, the key is released | executes again |
| Exception | no, the key is released | executes again |
| Streamed or file response | no, the key is released | executes again |
Configuration
config/idempotency.php (defaults). store, ttl and required can also be set from .env; malformed values fail loudly instead of falling back:
| Key | Default | Meaning |
|---|---|---|
header |
Idempotency-Key |
Request header with the key |
methods |
['POST', 'PATCH'] |
Protected methods; others pass through |
required |
false |
Reject requests without a key (per route: idempotent:required). Env: IDEMPOTENCY_REQUIRED |
ttl |
86400 |
Seconds a response is replayed (per route: idempotent:ttl=N). Env: IDEMPOTENCY_TTL |
lock_timeout |
30 |
Seconds a running request holds the key. Must exceed your slowest handler |
store |
cache |
cache, database or a class implementing IdempotencyStore. Env: IDEMPOTENCY_STORE |
cache_store |
null |
Cache store for cache (must support atomic locks); null = default |
database.connection / .table |
null / idempotency_keys |
For the database store |
replay_header |
Idempotent-Replayed |
Header added to replays |
store_status_codes |
[[200, 499]] |
Inclusive ranges that are stored (2xx, 3xx, 4xx); ranges ≥ 500 are rejected |
replay_headers |
['Content-Type', 'Location'] |
Response headers stored and replayed (never Set-Cookie unless you add it) |
scope_resolver |
UserScopeResolver |
Who owns a key |
error_responder |
JsonErrorResponder |
Renders 400/409/422 |
max_key_length |
255 |
Longest accepted key |
Choosing a store
cache (default) |
database |
|
|---|---|---|
| Needs | cache store with atomic locks (Redis recommended) | a table (migration included) |
| Speed | fastest | one indexed row per request |
| Survives cache eviction / Redis restart | no — an evicted key means a retry runs again | yes |
| Exactly-once execution | no (see Limitations) | no (see Limitations) |
| Cleanup | automatic (cache TTL) | php artisan idempotency:purge |
| Use for | general APIs | operations where a lost key is unacceptable (payments, payouts) |
php artisan vendor:publish --tag=idempotency-migrations php artisan migrate
// config/idempotency.php 'store' => 'database', // routes/console.php Schedule::command('idempotency:purge')->hourly();
Use a separate database connection
Point the store at its own connection (same database, separate PDO):
// config/database.php — a copy of your main connection under a new name 'connections' => [ 'idempotency' => [ 'driver' => 'pgsql', 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '5432'), 'database' => env('DB_DATABASE', 'laravel'), 'username' => env('DB_USERNAME', 'root'), 'password' => env('DB_PASSWORD', ''), // ... same options as your main connection ], ], // config/idempotency.php 'database' => ['connection' => 'idempotency', 'table' => 'idempotency_keys'],
Why: if the store shares the connection your application uses and a transaction is open on it while the middleware runs (a DB::transaction() around the request, a package that wraps requests in transactions, RefreshDatabase in tests), the claim and the stored response become part of your transaction. Other requests don't see the claim until commit (or block on the uncommitted row), and a rollback silently erases it. On a separate connection every claim and completion is committed immediately and is visible to all workers.
Scoping keys (users, API clients, tenants)
A key is scoped by owner + HTTP method + route template, so the same key from two users, or on two different endpoints, never collides.
The default UserScopeResolver uses the authenticated user's model class and id, and the client IP for guests.
For API clients or tenants, implement the contract:
use Azhimov\Idempotency\Contracts\ScopeResolver; use Illuminate\Http\Request; final class TenantScopeResolver implements ScopeResolver { public function resolve(Request $request): string { return 'tenant:'.$request->attributes->get('tenant_id').':user:'.$request->user()?->getAuthIdentifier(); } }
'scope_resolver' => App\Idempotency\TenantScopeResolver::class,
Custom error format
Implement Azhimov\Idempotency\Contracts\ErrorResponder and set error_responder. The middleware adds Retry-After to 409 itself.
final class ProblemJsonResponder implements ErrorResponder { public function respond(Request $request, ErrorCode $code, string $message): Response { return response()->json(['type' => $code->value, 'detail' => $message], $code->status(), [ 'Content-Type' => 'application/problem+json', ]); } }
Events
Listen to them for metrics, logs and alerts. Every event has $request and $key (the client key).
| Event | When | Extra |
|---|---|---|
IdempotentRequestStarted |
key claimed, handler about to run | |
IdempotentResponseStored |
response stored for replay | $status |
IdempotentResponseReplayed |
stored response replayed | $status |
IdempotencyKeyMismatch |
422, key reused with another request | |
IdempotencyConflict |
409, same request still running | $retryAfter |
IdempotencyLockLost |
handler finished after its claim expired and another request took the key over — the operation may have run twice | $status |
IdempotencyLockLost should page someone: it means lock_timeout is shorter than a real handler.
How it works
flowchart LR
C[Client] -->|POST + Idempotency-Key| M[EnsureIdempotency middleware]
M --> K[IdempotencyKey: validate]
M --> S[ScopeResolver: owner]
M --> F[Fingerprint: method, path, query, body]
M -->|begin / complete / release| ST{{IdempotencyStore}}
ST --> CS[CacheStore: per-key mutex]
ST --> DS[DatabaseStore: conditional SQL]
M -->|Started| H[Your controller]
M -.->|events| E[Listeners, metrics]
Loading
The store owns the lock. begin() atomically returns one of Started(token), InProgress(retryAfter), Completed(response) or Mismatch; complete() and release() only act while the caller still owns the record.
First request and replay
sequenceDiagram
autonumber
participant C as Client
participant M as Middleware
participant S as Store
participant H as Controller
C->>M: POST /payments, key K, body B
M->>S: begin(scope+K, fingerprint(B))
S-->>M: Started(token T)
M->>H: handle
H-->>M: 201 pay_1
M->>S: complete(T, 201 pay_1, ttl)
M-->>C: 201 pay_1
Note over C,M: connection dropped, client retries
C->>M: POST /payments, key K, body B
M->>S: begin(scope+K, fingerprint(B))
S-->>M: Completed(201 pay_1)
M-->>C: 201 pay_1, Idempotent-Replayed true
Loading
Concurrent retry and reused key
sequenceDiagram
autonumber
participant C as Client
participant M as Middleware
participant S as Store
participant H as Controller
C->>M: POST, key K, body B
M->>S: begin
S-->>M: Started(T)
M->>H: handle (slow)
C->>M: retry POST, key K, body B
M->>S: begin
S-->>M: InProgress(29s)
M-->>C: 409, Retry-After 29
C->>M: POST, key K, body B2
M->>S: begin
S-->>M: Mismatch
M-->>C: 422 idempotency_key_reused
H-->>M: 201
M->>S: complete(T)
M-->>C: 201
Loading
Lock expiry and fencing
sequenceDiagram
autonumber
participant A as Request A
participant B as Request B (retry)
participant S as Store
A->>S: begin
S-->>A: Started(T1), locked for lock_timeout
Note over A: handler is slower than lock_timeout
B->>S: begin (lock expired)
S-->>B: Started(T2), takeover
B->>S: complete(T2, response)
S-->>B: true, stored
A->>S: complete(T1, response)
S-->>A: false, T1 no longer owns the record
Note over A: IdempotencyLockLost dispatched, B's result is kept
Loading
Why it's built this way
The original design notes and the decisions behind them are in docs/DESIGN.md.
- The store owns the lock. No separate
Cache::lockheld around your controller: claiming the key is the lock. This lets the database store work without Redis at all. - Owner tokens (fencing). Every claim gets a fresh token;
complete()is compare-and-set on it. A request whose lock expired can't overwrite the result of the request that took over — and you getIdempotencyLockLostinstead of silent corruption. - Durable keys in the database store. Records are rows, so a cache eviction or a Redis restart can't erase a key and let a retry through. That closes one source of duplicates, not all of them (see Limitations). The claim is a primary-key
insertOrIgnore(no exception, so it never aborts a surrounding PostgreSQL transaction); takeover, complete and release are single conditionalUPDATE/DELETEstatements. - Cache store is atomic too. Every state transition runs as read-modify-write under a short per-key cache lock, not just the first
add. - Strict fingerprint. JSON key order, whitespace and form/query parameter order are ignored; values, value types (
1500vs"1500"), list order and uploaded file contents are not. A false 422 is better than replaying a response for a different payment. - Safe defaults. 5xx is never stored (and can't be configured to be); guests are scoped by IP, never share one namespace; only whitelisted headers are replayed (no
Set-Cookieleaks between retries). - A storage error never hides the real result. If storing the response fails after the operation ran, the client still gets the real response (the error is reported); the claim stays until
lock_timeout, so retries get 409 instead of running immediately — but afterlock_timeouta retry takes the key over and runs again (see Limitations).
Comparison with alternatives
Checked on Packagist on 2026-11-11. All of these are solid; pick by what you need.
| this package | square1/laravel-idempotency | wendelladriel/laravel-idempotency | infinitypaul/idempotency-laravel | grazulex/laravel-api-idempotency | |
|---|---|---|---|---|---|
| Downloads | new | ~308k | ~41k | ~40k | ~1k |
| Laravel | 12–13 | 12–13 | 13 | 9–13 | 12–13 |
| Lock against concurrent execution | ✅ 409 + Retry-After |
✅ 409 | ✅ | ✅ | ✅ wait / reject |
| Payload fingerprint | ✅ 422 | ✅ 422 | replays same data only¹ | ✅ | ✅ |
| Database store | ✅ | cache only | cache only | ¹ | ✅ (+ Redis, DynamoDB) |
| Lock expiry fencing (owner token) | ✅ | documented caveat | ¹ | ¹ | ¹ |
| 5xx stored by default | no (can't be enabled) | yes (configurable) | yes (configurable) | ¹ | ¹ |
| Events | ✅ 6 | ¹ | ¹ | telemetry + alerts | ✅ |
| Scope | custom resolver | user / ip / global | enum (e.g. ip) | ¹ | user / tenant / ip / custom |
| Extras this package doesn't have | throw-instead-of-replay mode | key from form input _idempotency_key |
Inspector telemetry, alert thresholds | DynamoDB, test assertions |
¹ Not described in the package's README at the time of writing — it may exist; corrections welcome.
Limitations
- The window between your business change and
complete(). The response is stored after your controller returns. If the process dies in between (OOM kill, deploy, server crash), or the store is unavailable at that moment, the operation has happened but its result is not recorded. The key stays claimed untillock_timeout(retries get 409), then a retry takes it over and runs the handler again. Neither store closes this window in v0.1: the business change and the idempotency record are written in separate transactions. Until v0.2, mitigate it at the domain level — a unique constraint on a business key (e.g.payments.client_reference) so a second execution fails instead of charging twice — keep handlers short, and alert onIdempotencyLockLostand on errors reported from the middleware. lock_timeoutmust be longer than your slowest handler. Otherwise a retry can take the key over while the first request is still running (you'll seeIdempotencyLockLost).- Guests are scoped by IP. Clients behind one NAT share it; prefer
idempotent:requiredon authenticated routes. Make sureTrustProxiesis configured so$request->ip()is the real client. - Streamed and file responses can't be captured; the key is released and a retry runs again.
- The cache store can lose keys under memory pressure or on a Redis restart; a lost key lets the next retry run again. Prefer the database store where that matters.
- The database store shares your transaction if it uses your application's connection — use a separate connection.
Roadmap
- v0.2 —
DatabaseStore::completeInTransaction(): an opt-in way to store the response in the same database transaction as the business change (the store and your models on one connection), so the result is committed atomically with the operation. This closes the window described in Limitations for database-backed operations.
Testing
composer check # Pint, Larastan (level max), Pest
Every store must pass the shared contract suite in tests/Support/StoreContract.php — use it for your own IdempotencyStore implementation.
Changelog
See CHANGELOG.md.
License
MIT. See LICENSE.