webrek / laravel-idempotency
Safe request retries for Laravel APIs via the Idempotency-Key header.
Requires
- php: ^8.2
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/routing: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- infection/infection: ^0.29
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0 || ^11.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Safe request retries for Laravel APIs. A client sends an Idempotency-Key
header with a write request; if that same request arrives again—a retry after a
timeout, a double-clicked button, a webhook redelivery—the original response is
replayed instead of executing the action twice.
Quickstart
composer require webrek/laravel-idempotency
Attach the middleware to routes that create or modify state:
Route::post('/orders', [OrderController::class, 'store']) ->middleware('idempotency');
Clients opt in per request by sending a unique key:
POST /orders HTTP/1.1 Idempotency-Key: 0f8fad5b-d9cb-469f-a165-70867728950e Content-Type: application/json {"sku": "ABC-123", "qty": 2}
The first call runs the controller and stores the response. Any repeat of that
call within the retention window returns the stored response verbatim, with an
Idempotency-Replayed: true header so the client can tell a replay apart from a
fresh result. Without a key there is no interception: existing callers keep
working.
The problem
POST is not safe to retry. When a client fires off a write request and the
connection drops before the response comes back, it has no way of knowing
whether the server processed it. Both options are bad: if you retry, you risk a
duplicate charge, order, or record; if you don't retry, you risk silently
losing the write.
Idempotency keys resolve the ambiguity. The client generates one key per logical operation and reuses it on every retry of that operation. The server promises that all requests sharing a key produce one execution and the same response. This is how Stripe, PayPal, Adyen, and most serious payment APIs make retries safe, and it is exactly what this package adds to your Laravel routes.
How it works
The middleware sits in front of your protected routes and does five things:
- Fingerprints the request. A SHA-256 of the method, the full URI
(including the query string), and the raw body is stored alongside the
response, so JSON and form-encoded bodies are compared byte for byte. For
multipart/form-datarequests, where PHP does not expose the raw body, the fingerprint instead covers the parsed fields (order-independent) and, for each uploaded file, its field path, original name, size, and content hash. If the same key later arrives with a different payload, that is a client error, and the request is rejected with422instead of silently returning the wrong cached response. - Serializes concurrent duplicates with an atomic lock. Two requests
carrying the same key at the same time cannot both run. The first takes the
lock and executes; the second gets
409 Conflictwith aRetry-Afterheader. The lock expires automatically, so a crashed worker never leaves a key stuck. - Replays the stored response. The status code, body, and a configurable set of headers are returned on subsequent hits, without touching your controller, your queued jobs, or your database.
- Leaves failures retryable. Server errors (
5xx) are never stored, so a client can safely retry after a transient failure. Transient client errors (408,425,429by default) are treated the same way. Successes and other deterministic client errors are replayed. - Never turns a completed request into an error. If the cache becomes
unreachable after your controller ran, the fresh response is still
returned: the failure is reported to your exception handler and an
IdempotencyStorageFailedevent fires. Nothing was stored, so a retry of that key executes again (at-least-once), which is the correct fallback — the alternative would be a500for work that already succeeded.
Everything lives in Laravel's cache, using the same atomic locks that
Cache::lock() exposes. There are no migrations and no new tables.
Behavior at a glance
| Scenario | Result |
|---|---|
| First request with a key | Executes, stores the response, Idempotency-Replayed: false |
| Same key, same payload, after completion | Replays the stored response, Idempotency-Replayed: true |
| Same key, same payload, still in progress | 409 Conflict + Retry-After |
Same key, still in progress, wait_for_completion > 0 |
Waits up to that many seconds, replaying as soon as the original finishes, otherwise 409 |
| Same key, different payload | 422 Unprocessable Entity |
No key (and require_key is false) |
Passes through untouched |
GET / HEAD request |
Ignored: already safe to repeat |
Response is 5xx |
Not stored: the next attempt re-runs it |
Response is 408, 425, or 429 |
Not stored by default (never_replay_status_codes): the next attempt re-runs it |
Response body exceeds max_body_size |
Not stored: the next attempt re-runs it |
| Cache unreachable after the controller ran | Fresh response returned, failure reported, IdempotencyStorageFailed fired; the next attempt re-runs it |
| Browser form request rejected (missing/invalid/conflicting key, in-progress) | Redirects back with the input re-flashed and a translated message, instead of throwing (redirect_back, session requests only) |
Requirements
| Component | Version |
|---|---|
| PHP | 8.2+ |
| Laravel | 12.x / 13.x |
| Cache store | Any store that supports atomic locks (redis, memcached, dynamodb, database, file, array) |
Configuration
The defaults are production-ready. Publish the configuration only if you need to change them:
php artisan vendor:publish --tag=idempotency-config
return [ // Header clients send to identify a retryable operation. 'header' => env('IDEMPOTENCY_HEADER', 'Idempotency-Key'), // Rejects keyless requests on protected routes with 400 when true. 'require_key' => false, // HTTP methods the middleware protects. GET/HEAD are already safe. 'methods' => ['POST', 'PUT', 'PATCH', 'DELETE'], // Cache store for responses and locks (null = default store). 'store' => env('IDEMPOTENCY_STORE'), 'prefix' => 'idempotency:', // How long a response stays replayable, in seconds. 'ttl' => (int) env('IDEMPOTENCY_TTL', 86400), // Maximum time a request holds the lock for its key, in seconds. A request // that runs longer than this may be executed twice by a concurrent retry. 'lock_timeout' => (int) env('IDEMPOTENCY_LOCK_TIMEOUT', 10), 'max_key_length' => 255, // Scopes keys per authenticated user so callers don't collide. 'scope_by_user' => true, // Null replays everything < 500 and not in never_replay_status_codes; or // list explicit codes, e.g. [200, 201, 422], to replay only those. 'replay_status_codes' => null, // Transient client errors that are never stored, even though they are // below 500. Only consulted when replay_status_codes is null. 'never_replay_status_codes' => [408, 425, 429], // Responses larger than this are never stored. 0 disables the limit. 'max_body_size' => 1024 * 1024, // Headers copied to the replayed response. Location is always persisted // even if removed from this list. 'persist_headers' => ['Content-Type', 'Location'], // Flag added to every protected response: "true" | "false". 'replay_header' => 'Idempotency-Replayed', // Form field read when the header is absent or blank. Rendered by the // `@idempotencyKey` Blade directive. Null disables the fallback. 'input' => '_idempotency_key', // Instead of an immediate 409, wait up to this many seconds for the // in-progress request to finish and replay its response. 0 disables it. 'wait_for_completion' => 0, // Re-flashes the session data (errors, old input, status) captured // alongside a stored response when it is replayed. 'replay_flash' => true, // Redirects back with a translated error instead of throwing, for a // request that carries a session and does not expect JSON. 'redirect_back' => true, // Key under which the translated rejection message is flashed to the // errors bag, e.g. $errors->first('idempotency'). 'error_key' => 'idempotency', ];
Per-route retention
Override the configured TTL (in seconds) for specific routes by passing it as a middleware parameter:
Route::post('/payments', ...)->middleware('idempotency:3600'); // 1 hour Route::post('/imports', ...)->middleware('idempotency:86400'); // 1 day
Web forms (Blade)
Classic HTML forms cannot send custom headers, their responses are redirects carrying session flash data instead of a JSON body, and a human — not a retrying HTTP client — is on the other side. This package supports that case too.
Add the hidden field to any form on a route guarded by the idempotency
middleware:
<form method="POST" action="/orders"> @csrf @idempotencyKey {{-- ... --}} </form>
@idempotencyKey renders a hidden input carrying a fresh UUID, using the
field name configured under input (default _idempotency_key):
<input type="hidden" name="_idempotency_key" value="9b1f2b1e-...-...">
The middleware reads this field only when the Idempotency-Key header is
absent or blank — a header, when present, always wins — so the same route
keeps working for API clients that send the header directly. Set input to
null to disable the field fallback and require the header everywhere.
With this in place:
- Double-click: a second click before the page navigates away resubmits the exact same in-memory form, hidden field included, so it replays the first response instead of creating a duplicate.
- F5 / reload on the response page, or back-then-forward: a browser's
native "resend form data" resubmits the exact same
POSTbody, hidden field included, so this replays too. - Validation error: this is where flash replay (below) matters. The
redirect back to the form is a fresh
GET, so@idempotencyKeymints a new value for that page load — correcting the input and resubmitting is a genuinely different submission and executes normally, it does not replay a stale error. What does replay is resubmitting the same invalid data a second time (double-click or resend, as above): previously that lost its error message and old input on the replay; now it shows them correctly. - Back button: only replays if the browser restores the exact prior
POST(native resubmission) rather than a freshGETre-render of the form.
Flash replay
A replayed redirect is, from the session's point of view, "the next
request": it ages the original flash data without re-flashing it, so a
naively replayed validation redirect would show no errors and no old input,
and a replayed success redirect would lose its status message. When
replay_flash is true (the default), the flash data set while the request
was first executed — errors, old input, status, or anything else flashed via
with() — is captured alongside the stored response and re-flashed before
the replay is returned, so the second submission's redirect looks exactly
like the first.
Laravel's cache stores refuse to unserialise objects unless they are
allowlisted (cache.serializable_classes, false by default), so the flash is
stored as plain data: validation error bags (ViewErrorBag, MessageBag) are
flattened on the way in and rebuilt on replay, scalars and plain arrays pass
through untouched, and any other object flashed via with() is dropped rather
than risk an incomplete class on replay.
That flash data lands in the same cache entry as the response, with the same
sensitivity as the session itself. Laravel already keeps password,
password_confirmation, and current_password out of the flashed old input,
so they are never captured either.
Human-friendly rejections
By default (redirect_back is true), the four client-facing rejections —
missing key, invalid key, conflict, in-progress — redirect back instead of
throwing, whenever the request carries a session and does not expect JSON
(API requests, and any request sending Accept: application/json, keep
getting the plain 400/409/422 response). The input is re-flashed
(except passwords and the input field above) and a translated message is
flashed to the errors bag under error_key (default idempotency):
@error('idempotency') <div class="alert alert-danger">{{ $message }}</div> @enderror
Translations ship in English and Spanish and are published with:
php artisan vendor:publish --tag=idempotency-lang
Waiting for an in-progress submission
wait_for_completion (default 0, seconds) waits briefly instead of
returning an immediate 409 when the same key is already being processed —
useful for a genuine double-click, where the first submission usually
finishes within a second or two. While waiting, the request polls the store
every 100 ms and replays the response the moment the original stores it; a
replay needs no lock, so any number of concurrent duplicates are answered at
once rather than one after another. If the original fails and frees the key
instead, the waiting request executes it. Keep the wait small (1-3 seconds)
on web workers; if it times out, the request falls back to the same
in-progress rejection described above.
TTL
Form routes are a good fit for a shorter, generous retention than the 24-hour API default — long enough to cover a double-click or an accidental reload, short enough that a genuinely new submission a day later isn't mistaken for a retry:
Route::post('/orders', ...)->middleware('idempotency:3600'); // 1 hour
Inertia, Livewire, and Filament
Inertia requests carry the Idempotency-Key header like any other client —
generate one UUID per form instance (e.g. on mount) and send it with the
request, the same as a JSON API consumer would; the input field fallback
above is not needed. Livewire and Filament actions are dispatched over JSON
and Livewire already guards against double-submission on the client side, so
they are out of scope for this feature.
Requiring a key on specific routes
Leave require_key disabled globally (the default) and require a key only on
the routes that need it by adding required as a middleware parameter:
Route::post('/payments', ...)->middleware('idempotency:required'); // key required, default TTL Route::post('/imports', ...)->middleware('idempotency:3600,required'); // key required, 1 hour TTL
A keyless request on a required route is rejected with 400 before doing
any work, regardless of the global require_key setting. Set require_key to
true instead if every protected route must carry a key.
Lock timeout
lock_timeout (or IDEMPOTENCY_LOCK_TIMEOUT) bounds how long a request may
hold its key's lock. A request that runs longer than this may be executed
twice by a concurrent retry that acquires the lock after it expires — size it
comfortably above your slowest guarded request.
Replay event
A Webrek\Idempotency\Events\IdempotentReplay event is dispatched every time a
stored response is replayed, so you can measure how many retries you are
absorbing:
use Webrek\Idempotency\Events\IdempotentReplay; Event::listen(IdempotentReplay::class, function (IdempotentReplay $event) { Metrics::increment('idempotency.replays', tags: ['key' => $event->key]); });
Storage failure event
Webrek\Idempotency\Events\IdempotencyStorageFailed is dispatched when the
response could not be stored (operation === 'put') or the key's lock could not
be released (operation === 'release') after the request already ran. The
exception is also reported through your exception handler. Alert on this event:
while it fires, retries are executing again instead of being replayed.
Event::listen(IdempotencyStorageFailed::class, function (IdempotencyStorageFailed $event) { Log::critical('idempotency store unavailable', [ 'operation' => $event->operation, 'key' => $event->key, 'exception' => $event->exception->getMessage(), ]); });
Choosing a cache store
Replays are only as durable as the store backing them. array is for testing;
in production point IDEMPOTENCY_STORE at redis (or any shared, persistent
store with atomic locks) so replays survive across web workers and deployments.
A per-process store like array cannot coordinate locks across machines.
Client guidance
- One key per logical operation, reused on retry. Generate a UUID before the first attempt and send the same value on every retry of that attempt. A new key per retry defeats the purpose.
- Handle
409by backing off and retrying: it means an earlier attempt is still running. Respect theRetry-Afterheader. - Treat
422as a bug on your side: it means you reused a key for a genuinely different request.
Comparison with homegrown approaches
| Approach | Concurrency-safe | Detects different payload | Replays the full response | Migrations |
|---|---|---|---|---|
firstOrCreate on a request_id column |
No (race between the check and the insert) | No | No | Yes |
| Unique DB constraint + catch duplicate | Partially (depends on the write reaching the constrained table) | No | No | Yes |
| This package | Yes (atomic lock) | Yes (request fingerprint) | Yes | No |
A unique constraint stops a duplicate row, but it does not stop the duplicate side effects that ran before the insert (the email already sent, the third-party charge already made), and it hands the client an error instead of the original success. Idempotency at the HTTP boundary stops the second execution entirely and returns the first response.
Testing
composer install
composer test
The suite runs on the array cache store, so no external services are needed.
Contributing
See CONTRIBUTING.md.
Security
Please review the security policy before reporting a vulnerability.
License
The MIT License (MIT). See LICENSE.