Idempotency keys
Clients send an Idempotency-Key header (draft-ietf-httpapi-idempotency-key-header-07). The first request runs; repeats replay the stored response with Idempotent-Replayed: true; the same key with another payload answers 422, and a request still in flight 409 with Retry-After. 5xx responses release the key so the client may retry.
Route::post('/orders', StoreOrder::class)->middleware(['auth:sanctum', 'sentinel.idempotent']); // optional key
Route::post('/payments', StorePayment::class)->middleware(['auth:sanctum', 'sentinel.idempotent:required']);
Route::post('/exports', StoreExport::class)->middleware('sentinel.idempotent:optional,3600'); // own TTL
// The same, validated when the route is declared (a TTL outside 60–2 592 000 throws):
Route::post('/payments', StorePayment::class)->middleware(['auth:sanctum', EnsureIdempotency::required()]);
Route::post('/exports', StoreExport::class)->middleware(EnsureIdempotency::optional(ttl: 3600));Place it after auth, so the key is scoped to the user.
Order of checks
- Methods outside idempotency.methods (POST, PATCH) pass through untouched.
- The header is parsed as an RFC 9651 string; with accept_unquoted a bare value is accepted too. Its length must be min_length..max_length (16..255). Invalid or repeated → 400 invalid_idempotency_key; absent → 400 idempotency_key_missing with :required, else pass-through.
- Scope: user:<guard>:<id> when authenticated, else sig:<ring>:<keyid> when a verified HTTP signature is on the request, else ip:<ip> — plus the route. The stored key is a digest of scope, route and key.
- Fingerprint: method, raw path, raw query, content type and a SHA-256 of the raw body. For multipart/form-data, the fields in order and every file’s client name, size and SHA-256 are bound instead.
| Situation | Response |
|---|---|
| No record, or an expired one (past its TTL and not held by a live lease) | Own it (processing) → run the handler |
| Another fingerprint (any state) | 422 idempotency_key_reused |
| processing, lease valid | 409 idempotency_request_in_progress + Retry-After |
| processing, lease expired (lock_seconds) | Take over (compare-and-swap on the owner token) → run the handler |
| completed, replayable | Replay the stored response + Idempotent-Replayed: true |
| completed, not replayable (streamed, too large) | 409 idempotent_response_unavailable |
| The stored record was edited (unreadable) | 409 idempotent_response_unavailable — never run twice on a guess |
What is stored
- Status < 500: the status, the headers in replayed_headers and the body — encrypted when idempotency.encrypt is on (the default) with AES-256-GCM under a key derived from APP_KEY for stored responses only, bound to the key’s digest and scope, so a response copied to another key or planted as plaintext is never replayed. 4xx only with store_client_errors (on by default).
- ≥ 500: the key is released so the client may retry (unless store_server_errors).
- Streamed or file responses and bodies over max_response_bytes are completed but not replayable. Set-Cookie is never stored or replayed.
- Keys expire ttl seconds (24 hours by default) after they were first seen — publish this in your API documentation — but never while a live lease holds them.
Rejections are RFC 9457 application/problem+json responses with a code member and fire IdempotencyRejected; replays fire IdempotentRequestReplayed. With idempotency.transactional on (database store only), the handler and the idempotency record commit in one transaction — exactly once for that connection’s writes, at the cost of holding the transaction for the request.
Programmatic runs
For jobs, commands and webhook processors:
$result = Sentinel::idempotency()->run(
"charge:{$order->id}",
scope: 'billing',
callback: fn () => $gateway->charge($order),
fingerprint: "order:{$order->id}:{$order->total}", // optional — reuse with another one → 422
ttl: 3600, // optional
lease: 900, // optional — above the callback's longest run
);
$result->value; // the JSON round-trip of the callback's result — the same on every run
$result->replayed; // true on repeats
$result->firstSeenAt;
Sentinel::idempotency()->forget("charge:{$order->id}", scope: 'billing');use RoundlyConsulting\Sentinel\Actions\Idempotency\RunIdempotentAction;
use RoundlyConsulting\Sentinel\DataTransferObjects\IdempotentCall;
$result = Sentinel::runIdempotent(new IdempotentCall("charge:{$order->id}", 'billing', fn () => $gateway->charge($order)));
Sentinel::forgetIdempotencyKey("charge:{$order->id}", 'billing'); // bool: whether it existed
$result = app(RunIdempotentAction::class)->execute(new IdempotentCall("charge:{$order->id}", 'billing', fn () => $gateway->charge($order)));- The key and the scope are 1–255 bytes each — the scope is never empty — ttl is 60–2 592 000 seconds and lease 1–86 400 seconds; anything else throws InvalidIdempotencyKeyException before the store is touched. Pick scopes of your own (billing, webhooks) rather than ones shaped like the HTTP scopes.
- fingerprint refuses a reuse of the key with other input (IdempotencyKeyReusedException).
- value is always what a replay returns — the JSON round-trip of the callback’s result — so $result->value['id'] works on the first run and on every retry.
- A callback that throws gives the key back. One that ran but returned something that cannot be stored (binary data, INF, a resource) is never run again: its key is completed without a result (IdempotentResultException) and a repeat is refused (IdempotentResponseUnavailableException, 409) instead of repeating the side effect.
- The lease is how long a running call holds the key; give a callback that may run longer than lock_seconds a lease above its longest run.
Queued jobs
The Idempotent job middleware runs a job at most once per key — a webhook redelivered as a second job, a double dispatch — beyond what ShouldBeUnique (only while queued) and WithoutOverlapping (only concurrently) cover:
use RoundlyConsulting\Sentinel\Jobs\Middleware\Idempotent;
final class HandleStripeEvent implements ShouldQueue
{
use InteractsWithQueue, Queueable;
public function __construct(public readonly StripeEvent $event) {}
public function middleware(): array
{
return [new Idempotent("stripe:{$this->event->id}", scope: 'webhooks')];
}
}new Idempotent(string $key, string $scope = 'jobs', ?int $ttl = null, int $releaseAfter = 10, ?int $lease = null) validates its arguments when built. The running job holds its key for its lease: lease when given, else the job’s $timeout, else its queue connection’s retry_after — never less than idempotency.lock_seconds, at most a day. The sync queue has no retry_after, so there a job without $timeout holds its key only for lock_seconds (60 seconds by default), and a duplicate arriving later runs beside a first run that is still going: give such a job a $timeout, or pass lease.
| Situation | Outcome |
|---|---|
| First run completes | The key is completed (result null). |
| A duplicate after that | Completes without running. |
| A duplicate while the first still runs | release(max(Retry-After, releaseAfter)) back onto the queue — or rethrows IdempotencyRequestInProgressException for a job without release(). |
| The job throws | The key is released; the queue retries or fails the job as usual. |
| The job release()s or fail()s itself | The key is released (its run did not complete), so the retry runs. |
Client side
Http::withIdempotencyKey()->post('https://api.example.com/orders', $payload); // a fresh UUIDv7
Http::withIdempotencyKey($order->uuid)->post('https://api.example.com/orders', $payload);Stores
database (the default) uses sentinel_idempotency_keys with a unique key digest, insert-or-ignore and an owner-token compare-and-swap. cache needs a store with atomic locks (refused at boot otherwise). Bind Contracts\IdempotencyStore for your own (see Extending). All stores share one decision table, and eight concurrent requests with the same key on PostgreSQL and MySQL yield exactly one owner.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.