spiral / idempotency
Idempotency guarantees (AtLeastOnce lease / ExactlyOnce inbox) for Spiral Framework operations.
Requires
- php: >=8.4
- psr/clock: ^1.0
- psr/log: ^2.0 || ^3.0
- spiral/boot: ^3.15
- spiral/serializer: ^3.15
- yiisoft/friendly-exception: ^1.1
Requires (Dev)
- cycle/database: ^2.21
- cycle/schema-builder: ^2.12
- cycle/skills: 1.x-dev
- cycle/transaction: ^1.1
- google/common-protos: ^3.1 || ^4.0
- google/protobuf: ^3.7 || ^4.0 || ^5.0
- infection/infection: ^0.33.2
- llm/skills: dev-master
- nyholm/psr7: ^1.8
- predis/predis: ^2.2
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- spiral/cycle-bridge: ^2.13
- spiral/dumper: ^3.3
- spiral/events: ^3.15
- spiral/interceptors: ^3.15
- spiral/queue: ^3.15
- spiral/roadrunner-bridge: ^4.0
- spiral/roadrunner-grpc: ^3.6
- testo/testo: ^0.10.20
- vimeo/psalm: ^7
Suggests
- cycle/database: Use the bundled Cycle driver (lease/inbox storage over a Cycle DBAL connection) — required by everything under Spiral\Idempotency\Driver\Cycle.
- cycle/transaction: Required by the bundled Cycle driver's inbox storage (single-transaction ExactlyOnce commit) — everything under Spiral\Idempotency\Driver\Cycle.
- predis/predis: Default client for the Redis/Valkey backends — RedisLeaseConfig (AtLeastOnce) and RedisAtMostOnceConfig (AtMostOnce) over a Redis-compatible server. Not needed when RedisCommands (Driver/Redis) is bound to an adapter over your own Redis client.
- spiral/cycle-bridge: Integrate the idempotency tables into the Cycle ORM schema (cycle:migrate / cycle:sync) via CycleSchemaBootloader — also pulls cycle/schema-builder.
- spiral/events: Make PSR-14 event listeners idempotent declaratively with #[Idempotent] via IdempotentListenerFactory, bound by EventsIdempotencyBootloader — a per-listener key, so one failing listener of a fan-out re-runs alone on the next dispatch.
- spiral/interceptors: Make HTTP controller actions idempotent declaratively with the #[Idempotent] attribute via IdempotencyInterceptor (also needs a PSR-17 factory).
- spiral/queue: Make queue/job handlers idempotent declaratively with #[Idempotent] via IdempotencyInterceptor bound in the 'queue' scope by QueueIdempotencyBootloader (Locked -> native job retry).
- spiral/roadrunner-bridge: Runs the gRPC (and queue) dispatcher in a Spiral app: provides config/grpc.php with its `interceptors` list, the 'grpc' dispatcher scope and the invoker that IdempotencyInterceptor plugs into. Required to actually serve gRPC; no type of it is imported here.
- spiral/roadrunner-grpc: Make gRPC service methods idempotent declaratively with #[Idempotent] via IdempotencyInterceptor bound in the 'grpc' scope by GrpcIdempotencyBootloader (Locked -> ABORTED status with a google.rpc.RetryInfo detail, key from metadata). Pulls google/protobuf + google/common-protos, which the gRPC middleware use directly.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-25 08:28:00 UTC
README
The package protects operations from duplicated side-effects on retries in Spiral Framework
applications. It ships two mechanisms with different guarantees — an AtLeastOnce lease
(lock + fencing token + response cache) and an ExactlyOnce-effect transactional inbox —
behind one declarative #[Idempotent] attribute and one programmatic API.
Installation
composer require spiral/idempotency
Optional packages:
cycle/database— the bundled storage driver (lease and inbox tables over a Cycle DBAL connection);spiral/interceptors— the declarative#[Idempotent]attribute viaIdempotencyInterceptor(also needs a PSR-17 factory for the HTTP middleware);spiral/queue— the queue/jobs transport (QueueIdempotencyBootloader,QueueKeyMiddleware,QueueRetryMiddleware), making consumed jobs idempotent withLocked → native job retry;spiral/events— the PSR-14 events transport (EventsIdempotencyBootloader,IdempotentListenerFactory), making individual#[Listener]methods idempotent with a per-listener key;predis/predis— the default client for the Redis/Valkey backends: the AtLeastOnce lease (RedisLeaseConfig) and the AtMostOnce dedup-guard (RedisAtMostOnceConfig), both over a Redis-compatible server (Valkey, Redis) with server-side TTL (no GC needed) and atomic Lua scripts. Not needed whenDriver\Redis\RedisCommandsis bound to an adapter over the app's own Redis client;spiral/cycle-bridge— integrates the idempotency tables into the ORM schema (cycle:sync/cycle:migrate).
Documentation
Two guarantees
Exactly-once delivery is impossible: between committing a side-effect and recording its completion there is always a crash window, so any retry may re-run the effect. What is achievable:
| Guarantee | Mechanism | When |
|---|---|---|
| AtLeastOnce | Lease: atomic conditional insert + fencing-token CAS; the result is cached and replayed | Always. The side-effect may repeat inside the crash window |
| AtMostOnce | Dedup-guard: the marker commits before the effect and is never removed, so a duplicate is refused (not re-run) | When losing the effect is safer than repeating it; a repeat is not safe to retry |
| ExactlyOnce (effect) | Inbox: INSERT ... ON CONFLICT DO NOTHING + the side-effect commit in one DB transaction |
Only when the side-effect writes to the same database as the inbox record |
Use the lease for non-transactional effects (calling a payment gateway, sending an email) and the
inbox when the whole effect lives in your database (creating an order). The dedup-guard is for
fire-and-forget jobs/events where the effect must run at most once and a crash between marker and
effect may lose it. A duplicate is never re-run and never handed an invented answer: with
cacheResult: true it replays the stored result (a null result included), and when no outcome is
stored — the cache is off, or the first run has not written its result (it may still be running, or
it crashed or threw: the marker and the result are not committed atomically) — execute() throws
Exception\OutcomeUnknownException. Without the cache no outcome is ever stored; with it, a later
retry may replay one. Each transport maps that signal: HTTP 409 Conflict without Retry-After, gRPC
ABORTED without a retry hint, a queue job is ACKed, an event listener is skipped.
Note
Upgrading: a Cycle at-most-once row written by an earlier release for a first run that returned null with
cacheResult on holds a NULL result, so its duplicate now answers as an unknown outcome instead of
replaying null.
Quick start
Register the bootloaders:
// app/src/Application/Kernel.php public function defineBootloaders(): array { return [ // ... \Spiral\Idempotency\Bootloader\IdempotencyBootloader::class, // opt-in: HTTP wiring for the #[Idempotent] attribute \Spiral\Idempotency\Bootloader\HttpIdempotencyBootloader::class, // opt-in: idempotency tables in the ORM schema (cycle:sync / cycle:migrate) \Spiral\Idempotency\Bootloader\CycleSchemaBootloader::class, ]; }
Describe the storages and the HTTP middleware stack:
// app/config/idempotency.php use Spiral\Idempotency\Driver\Cycle\CycleInboxConfig; use Spiral\Idempotency\Driver\Cycle\CycleLeaseConfig; use Spiral\Idempotency\Http\HttpKeyMiddleware; use Spiral\Idempotency\Http\HttpOutcomeMiddleware; return [ 'default' => 'payments', // Resolution middleware, outer → inner. The outcome middleware sits OUTERMOST: it marshals // the response (replay headers, Locked → 409, missing key → 400); the key middleware inside // it extracts and normalizes the key. 'transports' => [ 'http' => [ HttpOutcomeMiddleware::class, HttpKeyMiddleware::class, ], ], // Semantic aliases: the handler code names an alias, the driver and the declared guarantee // live here. The declared guarantee is verified against the driver capability at bootstrap. 'storages' => [ 'payments' => new CycleLeaseConfig( table: 'idempotency_lease', lockTtl: 30, // seconds the PROCESSING lock is held retentionTtl: 3600, // seconds the cached result is kept ), 'orders' => new CycleInboxConfig( table: 'idempotency_inbox', ), ], ];
default names the alias an #[Idempotent] without a storage: argument falls back to, so a
single-storage application never repeats the alias in business code. It is optional: omit it and
storage: becomes mandatory — an attribute that then omits it throws MisconfigurationException
before the action runs (never as a 4xx/5xx response: a misconfiguration is not an outcome of the
call), and for an event listener already when the listener is registered.
A default pointing at an alias absent from storages fails at bootstrap, not on the first call.
transports is optional as a whole — omit it when no transport bootloader is registered (the
IdempotencyRegistry::execute() usage below). Once one is registered, its own entry becomes
mandatory: a missing transports.<name> throws MisconfigurationException on the first
#[Idempotent] call instead of running an empty pipeline that deduplicates on nothing. An explicit
'http' => [] is valid — the key then comes only from the attribute — but an empty stack also drops
the transport's outcome mapping: the Locked → 409 / ABORTED / job re-enqueue and the AtMostOnce
duplicate → 409 / ABORTED / job ACK translations live in the outcome middleware (HttpOutcomeMiddleware,
GrpcOutcomeMiddleware, QueueRetryMiddleware). Without it those exceptions propagate unmapped — on
the queue, to Spiral's retry policy as plain failures. Keep at least the outcome middleware.
Storage drivers available under storages:
| Config | Guarantee | Backend |
|---|---|---|
Driver\Cycle\CycleLeaseConfig |
AtLeastOnce | Lease table over a Cycle DBAL connection |
Driver\Cycle\CycleInboxConfig |
ExactlyOnce | Inbox table; the record and the side-effect commit in one transaction |
Driver\Cycle\CycleAtMostOnceConfig |
AtMostOnce | Dedup-guard table |
Driver\Redis\RedisLeaseConfig |
AtLeastOnce | Redis/Valkey hash with server-side TTL — no GC needed |
Driver\Redis\RedisAtMostOnceConfig |
AtMostOnce | Redis/Valkey SET NX EX marker; its TTL (retentionTtl, default 86400 s) is the dedup window — no GC needed |
Driver\Memory\MemoryLeaseConfig |
AtLeastOnce | Per-process PHP array. Dedup holds only inside the worker that acquired the lease and dies with it — for tests and local development, not for production traffic |
Both Redis configs take the connection from the container: a Driver\Redis\RedisCommands binding (an
adapter over any client), else a \Predis\ClientInterface binding. The lease needs Redis 4.0+
(multi-field HSET); the at-most-once marker runs on Redis 3.2+; any Valkey runs both. A fire-once
job storage on Valkey:
use Spiral\Idempotency\Driver\Redis\RedisAtMostOnceConfig; 'storages' => [ 'jobs' => new RedisAtMostOnceConfig( keyPrefix: 'idempotency-amo:', // must not start with a RedisLeaseConfig prefix, nor it with this one retentionTtl: 86400, // dedup window: a duplicate after it RE-EXECUTES cacheResult: false, // true: best-effort replay of the result instead of OutcomeUnknownException ), ],
The marker is set before the operation and never removed — a throwing operation is not re-run, whatever
the failure policy. cacheResult writes the result as a second step after the effect: a crash, or a
Redis error (logged, not thrown), between the two loses the replay (that duplicate gets the
unknown-outcome answer), never the dedup; a null result is stored and replayed too. Unlike
CycleAtMostOnceConfig, whose markers live forever by default, a Redis marker always expires, and its
window counts from the claim, not from completion: an operation that outlives it lets a concurrent
duplicate claim the key and run the effect again. Pick a retentionTtl above the longest operation plus
the longest redelivery/replay delay; ttl: on #[Idempotent] overrides it per operation (below 1 it throws
before the effect, like a retentionTtl below 1 fails when the storage is built).
Warning
A marker the server drops early re-opens the key to a duplicate, so the at-most-once storage needs a
server that keeps its volatile keys: maxmemory-policy noeviction (any volatile-*/allkeys-*
policy may evict markers, volatile-ttl the short-lived ones first — use a dedicated instance if a
shared one must evict) and AOF persistence (a restart from an RDB snapshot or none loses recent
markers). Replication is asynchronous, so a failover can still lose the latest claims.
Add one line to your existing domain-core interceptor list — reference the
IdempotencyInterceptor alias, not a concrete class:
use Spiral\Idempotency\Interceptor\IdempotencyInterceptor; final class AppBootloader extends DomainBootloader { protected const INTERCEPTORS = [ // ...your existing interceptors (Cycle, Guard, ...) — innermost after auth: IdempotencyInterceptor::class, ]; }
Mark an action:
use Spiral\Idempotency\Attribute\Idempotent; final class PaymentController { #[Route(route: '/payments/charge', methods: 'POST')] #[Idempotent(storage: 'payments')] public function charge(): ResponseInterface { // charge the gateway, return the response — it will be cached and replayed } }
Create the tables with the project's normal workflow: php app.php cycle:sync (or generate a
migration with cycle:migrate).
Note
Reference the IdempotencyInterceptor alias, with no scope. The DomainBootloader
interceptor list is resolved in the root container, where the alias is a forwarding proxy: on every
call it resolves the real, transport-flavored interceptor from the active dispatcher scope (the
http scope, where HttpIdempotencyBootloader bound it; the queue scope for
QueueIdempotencyBootloader). So the same domain core works in any scope, and every transport
reuses the same alias. Referencing the concrete
PipelineIdempotencyInterceptor class instead would fail — it is deliberately unbound in root. Invoking the
proxy outside a transport scope fails fast with a friendly MisconfigurationException.
HTTP behaviour
The client generates a key and sends it with every retry of the same operation:
curl -X POST /payments/charge -H 'Idempotency-Key: pay-42' -d 'amount=500'
| Situation | Response |
|---|---|
| First call | The action runs; the whole response is snapshotted. Headers: Idempotency-Key, Idempotency-Replay: false |
| Retry after completion | The cached response is replayed byte-identically, Idempotency-Replay: true; the action does not run |
| Retry while the first call is still in flight | 409 Conflict + Retry-After (lease storages) |
AtMostOnce duplicate with no stored outcome (cacheResult off; or on, and the first call is still running, crashed or threw before storing one) |
409 Conflict without Retry-After — with cacheResult off no outcome is ever stored; with it on, a later retry may replay one — with Idempotency-Key, Idempotency-Replay: true and a JSON body ("error": "outcome_unknown"); the action does not run |
| No key supplied | 400 Bad Request with a JSON body |
| A nested idempotent call inside the action is locked | 409 Conflict + Retry-After under the request's own Idempotency-Key, message "An operation this request depends on is already being processed." |
A nested idempotent call throws OutcomeUnknownException |
Not answered as a duplicate. A final one is Domain: with a DomainFailureRenderer bound it may be rendered into a response that is cached and replayed; otherwise it is rethrown and the application renders it (500 by default). On a lease the outer key caches a final one and releases a non-final one, which is always rethrown (see Failure classification) |
The action responded 5xx |
Lease: not cached — the key is released and a retry re-runs the operation (configurable predicate of HttpOutcomeMiddleware). Inbox and AtMostOnce never re-run: the inbox (and AtMostOnce with cacheResult) stores the 5xx and replays it; without cacheResult an AtMostOnce duplicate gets the 409 above |
By default HttpKeyMiddleware reads the Idempotency-Key header, then the key body/query field
(both names are constructor-configurable).
Queue / Jobs behaviour
The same interceptor makes queue/job handlers idempotent — a broker delivers at-least-once, so a
redelivered job must not double-run its side-effect. Register the queue bootloader and list the queue
middleware under transports.queue:
// Kernel::defineBootloaders() \Spiral\Idempotency\Bootloader\QueueIdempotencyBootloader::class,
// app/config/idempotency.php use Spiral\Idempotency\Queue\QueueKeyMiddleware; use Spiral\Idempotency\Queue\QueueRetryMiddleware; 'transports' => [ 'queue' => [ QueueRetryMiddleware::class, // outer: maps failure modes back to the transport QueueKeyMiddleware::class, // inner: extracts the key from the job header ], ],
Register the interceptor on the consume side (same alias, no concrete class) — either in
app/config/queue.php under interceptors.consume, or via the bootloader:
use Spiral\Idempotency\Interceptor\IdempotencyInterceptor; $queue->addConsumeInterceptor(IdempotencyInterceptor::class);
Important
Keep Spiral's default RetryPolicyInterceptor outer of the idempotency interceptor in the
consume list: QueueRetryMiddleware re-throws a Locked collision as a RetryableLockException,
and it is RetryPolicyInterceptor that catches it and re-enqueues the job with the carried delay.
We reuse the framework's retry engine rather than reimplementing backoff.
A failing job releases its key (FailurePolicy::Release is the queue default), so the redelivery
the retry policy schedules actually re-runs the handler instead of replaying the cached failure. Pass
failurePolicy: FailurePolicy::Cache on the attribute for a job whose failure is a final answer.
Mark a job handler — the key comes from the payload via the attribute's key path, or from a
job header the producer set (Options::withHeader('Idempotency-Key', ...)):
#[Idempotent(storage: 'orders', key: 'orderId')] // dot-path over the job payload public function invoke(string $orderId, array $payload): void { // runs once per orderId; a redelivered job replays / is skipped }
When the producer is outside the application — a CDC pipeline publishing from a transactional outbox
table, say — the job carries no headers at all. Enable the job-id fallback: the broker job id
(the id argument of the consume call context) becomes the key material. Mind what it identifies: one
broker message, not the logical operation. The id is the same when the broker redelivers the message
(consumer crash, missing ack); a job re-published by a retry policy or pushed a second time by the
producer carries a new one, and whether a driver keeps the id across its own requeue is driver-specific.
To deduplicate the operation itself, keep a key from the payload (key: 'payload.<field>') or the
header. The fallback is off by default so a producer that merely forgot the header does not get silent,
weaker deduplication:
// a bootloader of the app — the config stack lists class names, so bind the configured instance QueueKeyMiddleware::class => static fn(KeyResolver $resolver): QueueKeyMiddleware => new QueueKeyMiddleware($resolver, fallbackToJobId: true),
The header still wins when present, and the key scope (operation identity by default) still namespaces the result, so two job types redelivered with the same broker id do not collide.
| Situation | Outcome |
|---|---|
| First delivery | The handler runs; the guarantee records the effect (inbox commit / lease + cache) |
| Redelivery after completion | Deduplicated — the handler does not re-run (ExactlyOnce/AtLeastOnce), the job is ACKed |
| Delivery while another worker holds the key | LockedException → RetryableLockException → the job is re-enqueued after the lock TTL (not dead-lettered) |
| A nested idempotent call inside the handler is locked | Re-enqueued the same way |
A nested idempotent call throws OutcomeUnknownException |
Rethrown, not ACKed — the job fails; the queue default FailurePolicy::Release frees a lease key either way |
| No key resolvable (bad payload/header) | MissingKeyException propagates — not retryable, so the job dead-letters instead of retrying forever |
AtMostOnce (dedup-guard) duplicate |
The handler does not re-run and the job is ACKed (fire-once); this is the natural home for AtMostOnce. QueueRetryMiddleware turns the driver's OutcomeUnknownException into a null result, so the signal never reaches the retry policy — keep it in the stack |
For transient failure retries (infra errors), compose Spiral's own #[RetryPolicy] attribute on
the handler alongside #[Idempotent] — the two are orthogonal: idempotency dedups the effect, the
retry policy governs re-delivery.
A job whose entry point lives on an abstract base — the common "handle() implemented once, subclasses
implement invoke()" shape — annotates the class instead; there is no method of its own to mark:
#[Idempotent(storage: 'orders')] // key from the job header final class DispatchEvent extends JobHandler {} // handle() is inherited from JobHandler
gRPC behaviour
The same interceptor makes gRPC service methods idempotent. Register the gRPC bootloader, list the
gRPC middleware under transports.grpc, and add the interceptor to config/grpc.php:
// Kernel::defineBootloaders() \Spiral\Idempotency\Bootloader\GrpcIdempotencyBootloader::class,
// app/config/idempotency.php use Spiral\Idempotency\Grpc\GrpcKeyMiddleware; use Spiral\Idempotency\Grpc\GrpcOutcomeMiddleware; 'transports' => [ 'grpc' => [ GrpcOutcomeMiddleware::class, // outer: response/status snapshot, Locked → ABORTED GrpcKeyMiddleware::class, // inner: key from the `idempotency-key` metadata entry ], ],
The client sends the key as metadata; the server-side key middleware reads idempotency-key
(case-insensitively, configurable).
| Situation | Outcome |
|---|---|
| First call | The method runs; the protobuf response message is snapshotted. Response metadata: idempotency-key, idempotency-replay: false |
| Retry after completion | The cached message is rebuilt and returned with idempotency-replay: true; the method does not run |
| Retry while the first call is in flight | ABORTED — the status gRPC recommends for "retry at a higher level" (the analog of HTTP 409) — with a google.rpc.RetryInfo detail carrying the suggested delay (the analog of Retry-After) |
| No key in the metadata | INVALID_ARGUMENT (the analog of HTTP 400) |
| A nested idempotent call inside the method is locked | ABORTED + RetryInfo, message "An operation this request depends on is already being processed (retry after Ns)." |
A nested idempotent call throws OutcomeUnknownException |
Not answered as a duplicate. A final one is Domain: with a DomainFailureMapper bound it may be mapped to a status that is snapshotted and replayed; otherwise it is rethrown — a worker error rather than a status. On a lease the outer key caches a final one and releases a non-final one, which is always rethrown (see Failure classification) |
The method threw a GRPCException |
The status is snapshotted (code + message + details) and replayed identically, exact subclass included |
... with a transient status (UNAVAILABLE, DEADLINE_EXCEEDED, INTERNAL, ...) |
Lease: not cached — the key is released and a retry re-runs (configurable predicate of GrpcOutcomeMiddleware). Inbox and AtMostOnce never re-run: the inbox (and AtMostOnce with cacheResult) stores the status and replays it; without cacheResult an AtMostOnce duplicate gets the ABORTED above |
AtMostOnce duplicate with no stored outcome (cacheResult off; or on, and the first call is still running, crashed or threw before storing one) |
ABORTED without the RetryInfo detail — with cacheResult off no outcome is ever stored; with it on, a later retry may replay one — with idempotency-replay: true; the method does not run |
A null outcome (void operation, first call or replay) |
The method's declared response type is returned empty — gRPC has no "empty ACK", and the bridge's invoker requires a Message |
Unlike HTTP, no configuration is needed to keep a failure replay faithful: over gRPC a negative
outcome is a status, and code + message + details is a complete, deterministic snapshot of what
the client sees. Bind a DomainFailureMapper only if the service throws plain domain
exceptions instead of GRPCException — that mapping otherwise happens above the interceptor, too late
to be cached, and the replay would answer with a different status.
Infrastructure and Bug failures (including a GRPCException marked Retryable) are never
snapshotted: they stay exceptions so the key is released and a retry re-runs.
Events behaviour
The same attribute makes PSR-14 event listeners idempotent, one listener method at a time. The
target is a durable fan-out: an event re-published by an outbox (or dispatched again by a redelivered
job) reaches several #[Listener] methods, and a failure in one of them must not re-run the others.
Register the events bootloader — there is no transports.events config section and no interceptor to
wire, because events are dispatched through PSR-14 rather than through a domain core:
// Kernel::defineBootloaders() \Spiral\Idempotency\Bootloader\EventsIdempotencyBootloader::class,
It replaces the framework's ListenerFactoryInterface with IdempotentListenerFactory, which wraps
each marked listener method in its storage. The factory is the integration point on purpose: the
dispatcher sees only an opaque list of closures, while the factory still knows which listener a
closure belongs to — the identity a per-listener key needs.
Give the event a stable identity by implementing HasIdempotencyKey, then mark the listeners:
use Spiral\Idempotency\Events\HasIdempotencyKey; final class OrderPlaced implements HasIdempotencyKey { public function __construct(public readonly string $id) {} // outbox message_id, not a fresh uuid public function idempotencyKey(): string { return $this->id; } } final class PaymentService { #[Listener] #[Idempotent(storage: 'events')] public function charge(OrderPlaced $event): void {} #[Listener] #[Idempotent(storage: 'events')] public function notify(OrderPlaced $event): void {} }
An application whose events already share a base contract adopts the interface once, rather than per event class:
interface DomainEvent extends HasIdempotencyKey {} trait WithEventId { public readonly Uid $id; // the outbox message_id, preserved across re-publications public function idempotencyKey(): string { return $this->id->rawValue(); } }
Every event using the trait is then a valid target for a marked listener without a key path; keep the identity the producer preserves, not one regenerated per dispatch.
The key can also come from the attribute's key arg-path, resolved over the single argument event
(key: 'event.id') — use it for an event you do not own. An event with neither source throws
MissingKeyException: a listener marked idempotent must not deduplicate on nothing.
| Situation | Outcome |
|---|---|
| First dispatch | Every marked listener runs, each under its own key (scope = ListenerClass::method) |
| Re-dispatch of the same event | Each marked listener that completed is skipped; an unmarked one runs again |
| One listener of the fan-out threw | The dispatcher stops at it (PSR-14 semantics); on a lease or inbox storage the re-dispatch skips the listeners that completed and re-runs only the failed one. An AtMostOnce listener is never re-run — its key was consumed before it threw |
| Concurrent dispatch of the same event | LockedException propagates untouched — the durable producer that dispatched the event owns the retry |
AtMostOnce duplicate with no stored outcome |
The listener is skipped silently — its key is consumed whether the first run completed, failed or is still running; the fan-out goes on |
Event with neither key path nor HasIdempotencyKey |
MissingKeyException |
A failing listener releases its key (FailurePolicy::Release is the events default), which is what
makes the partial re-run work. Pass failurePolicy: FailurePolicy::Cache on the attribute for a
listener whose failure is a final answer.
Important
The event identity must be the one the producer preserves across re-publications of the same logical
event — an outbox message_id, not a value regenerated per dispatch. A fresh id makes every
redelivery look like a new event and silently disables deduplication.
The #[Idempotent] attribute
#[Idempotent(storage: 'payments', key: 'command.orderId', lockTtl: 60, ttl: 86400, scope: null)]
| Parameter | Meaning |
|---|---|
storage |
Semantic alias from the config — the only infrastructure reference in business code. Omit it to take the config's default alias |
key |
Dot-notation path over the call arguments, resolved by ArgumentKeyResolver: the leaf may be a non-boolean scalar, a Stringable (domain id) or a backed enum. null lets a transport middleware supply the key (HTTP header/field). A path that resolves to nothing fails fast |
lockTtl |
Override of the PROCESSING lock TTL, seconds (lease driver only) |
ttl |
Override of the completed-record retention TTL, seconds (lease storages). On RedisAtMostOnceConfig it is the dedup window, counted from the claim (below 1 it throws before the effect); CycleInboxConfig and CycleAtMostOnceConfig ignore it |
scope |
Key namespace, see below |
failurePolicy |
What a thrown failure does to the key: Cache (replay it) or Release (free the key, the next call re-runs). null = the transport default, see below |
The attribute goes on a method or on a class. On a class it covers every method the
transport dispatches to on that class, including ones inherited from an abstract base — the only way
to annotate a handler that never redeclares its entry point. On a job handler that is the single
handle(); on a controller it makes every action idempotent under the one storage alias, so reach
for a class attribute there only when that is what you mean. Resolution order, first hit wins:
- the dispatched method;
- the concrete class of the target;
- its parents, nearest first.
So a method attribute beats a class one, and a subclass beats the base it inherits from.
Keys are namespaced by operation identity so that the same client key sent to two different endpoints never replays a foreign response:
scope |
Key space |
|---|---|
null (default) |
Controller::method — safe per-operation isolation |
'payment-flow' |
Explicit name — intentionally shared by several endpoints |
Idempotent::SCOPE_GLOBAL |
No namespacing — the client is responsible for global uniqueness |
The default scope names the concrete class, not the one that declares the method, so sibling subclasses sharing one inherited entry point get one key space each.
ExactlyOnce: write through the transaction
The inbox driver opens a database transaction that carries both the dedup record and your
side-effect. Inside the operation, narrow the context to CycleContext and write through it —
this is the contract that makes the effect exactly-once:
use Spiral\Idempotency\Driver\Cycle\CycleContext; use Spiral\Idempotency\IdempotencyContext; #[Idempotent(storage: 'orders', key: 'command.orderId')] public function place(PlaceOrder $command, IdempotencyContext $ctx): array { \assert($ctx instanceof CycleContext); // ORM entities: a scoped Unit of Work flushed inside the transaction $ctx->entityManager()->persist(new Order(...)); // ...or raw DBAL through the transactional connection $ctx->database()->insert('order_events')->values([...])->run(); return ['status' => 'placed']; }
Crash before COMMIT → everything rolls back → a retry is clean. Crash after COMMIT → the retry
sees the dedup conflict, skips the operation and replays the stored result. No window.
Important
The guarantee only holds for effects written through the transactional connection. Anything external — an HTTP call, a message queue, another database — degrades the operation to AtLeastOnce no matter which driver runs it.
Programmatic usage
When the key is already known, skip the attribute and call the driver directly:
use Spiral\Idempotency\ExecuteOptions; use Spiral\Idempotency\FailurePolicy; use Spiral\Idempotency\IdempotencyContext; use Spiral\Idempotency\IdempotencyRegistry; public function __construct( private readonly IdempotencyRegistry $registry, ) {} public function handle(string $transactionId): Receipt { return $this->registry->get('payments')->execute( $transactionId, static fn(IdempotencyContext $ctx): Receipt => /* the operation */, new ExecuteOptions(lockTtl: 60, ttl: 86400, failurePolicy: FailurePolicy::Cache), ); }
A replayed null is a stored outcome. An AtMostOnce storage with no stored outcome throws
Exception\OutcomeUnknownException (carrying the key, and final — true when no outcome can ever appear) instead of returning null; catch it where
"already consumed, answer unknown" needs handling. Without cacheResult that holds for good; with it,
the first run may still be running, and a later retry may replay its result. A lease storage still throws LockedException while
another call holds the key.
The programmatic path applies no automatic namespacing — compose the final key yourself
(KeyResolver is available as a service and supports parentKey hierarchies for
composing multi-step chains).
Failure classification
An exception thrown by the operation is classified into one of three kinds — deterministic outcome and "will a retry help" are independent axes:
| Kind | Default mapping | Lease reaction |
|---|---|---|
| Domain | any other \Exception, a RetryableExceptionInterface that is not retryable, or a final OutcomeUnknownException |
Cached as a valid negative outcome, replayed on retry |
| Infrastructure | \Error, \Exception implementing Retryable, a retryable RetryableExceptionInterface, a LockedException, or a non-final OutcomeUnknownException |
The key is released; the transport/client retries |
| Bug | Only by explicit configuration (DefaultFailureClassifier(bugExceptions: [...])) |
The key is released; no re-enqueue — report and fix |
A LockedException or OutcomeUnknownException reaching the classifier comes from a nested
idempotent call inside the operation and describes the inner key's state. A lock is transient
contention: caching it would replay it for the whole retention TTL after the inner lock is gone, so an
outer lease key is released and a retry re-runs (an outer inbox rolls back, with the same effect). An
outer AtMostOnce key is consumed before the operation runs, whatever is thrown: its retry gets the
unknown-outcome answer. An unknown outcome depends on $final, set by the inner
storage: true (AtMostOnce with cacheResult off) means the inner key answers the same on every call,
so re-running the outer operation would only repeat its other effects — it is cached as Domain;
false (cacheResult on, the inner result may still be stored) releases the outer key like a lock.
RetryableExceptionInterface is spiral/queue's own retry contract: a job exception that already
states its retry intent for the broker is read the same way here, so it needs no second marker. The
rule is skipped when spiral/queue is not installed. It is checked before the \Error rule, so an
\Error implementing the contract follows isRetryable() rather than its type — the one combination
whose classification changed in 0.4.
A cached domain failure is replayed as CachedDomainFailureException carrying the original class
name and message. For an exact-type replay, implement ReplayableFailure on the
domain exception:
final class PaymentDeclined extends \DomainException implements ReplayableFailure { public function toReplayPayload(): array { return ['code' => $this->code, 'reason' => $this->reason]; } public static function fromReplayPayload(array $payload): static { return new self($payload['code'], $payload['reason']); } }
Per-operation failure policy
Classification decides what kind of failure happened; FailurePolicy decides whether the operation
may run again under the same key at all:
| Policy | Effect on a thrown failure |
|---|---|
Cache |
The table above applies: a Domain failure becomes a cached negative outcome and is replayed |
Release |
Any failure frees the key and the original throwable is rethrown unchanged — the next call re-runs the operation |
The default comes from the transport, and follows who owns the retry: queue → Release (the broker
redelivers the job), HTTP and gRPC → Cache (the client repeats the call itself and must see the
same answer). A direct execute() without options also caches. Override per operation on the attribute
or in ExecuteOptions:
use Spiral\Idempotency\FailurePolicy; #[Idempotent(storage: 'jobs', key: 'event.id', failurePolicy: FailurePolicy::Release)]
This is a lease/AtLeastOnce contract. The inbox (ExactlyOnce) and at-most-once drivers ignore it: an inbox failure already rolls the dedup row back with the side-effect, and a committed record of either driver is terminal — releasing it would let the effect run twice.
Consistent HTTP status for a thrown domain failure
Warning
A thrown domain failure bypasses the response snapshot, so the two attempts are rendered by different code: the first by your exception handler, the replay from the cached snapshot. Over HTTP that means a different status code — unless you do one of the three things below.
Three ways to keep the replay identical to the first attempt, best first:
-
Return an error response instead of throwing — it is snapshotted and replayed byte-identically, status included. Nothing to configure.
-
Bind a
DomainFailureRenderer— keeps the throwing style:HttpOutcomeMiddlewarerenders Domain-classified throwables into a response inside the operation, so the outcome is cached as a response snapshot and the replay carries the same status and theIdempotency-Replayheader:final class DeclineRenderer implements DomainFailureRenderer { public function __construct(private ResponseFactoryInterface $responses) {} public function render(\Throwable $failure): ?ResponseInterface { // Return null for failures this renderer does not own — they are rethrown untouched. return $failure instanceof PaymentDeclined ? $this->responses->createResponse(402) : null; } }
Bind it in a bootloader (
DomainFailureRenderer::class => DeclineRenderer::class) — the middleware picks it up by autowiring. OnlyFailureKind::Domainfailures reach the renderer: Infrastructure and Bug ones stay exceptions, so the key is still released and a retry re-runs. Thecacheablepredicate still applies, so a rendered 5xx is not cached on a lease either. Rows written before the renderer was bound keep replaying as a throw. -
Map
CachedDomainFailureException::$originalClassin the application exception handler.
Customization
- Middleware — both pipelines are open: implement
ResolutionMiddleware(transport phase, key extraction / outcome mapping) and list it undertransports.<name>, orExecutionMiddleware(domain phase around the operation). - Serializer — cached results are serialized with
spiral/serializer(PhpSerializerby default); bind your ownSerializerInterfaceto switch, e.g. to JSON. - Classifier — bind
FailureClassifierto replace the default failure mapping. - Domain failure rendering — bind
DomainFailureRendererto turn thrown domain failures into cached HTTP responses, so a replay reproduces the same status (see above). - Key policy — bind
KeyResolverto change normalization, hashing and hierarchy composition, orArgumentKeyResolverto change how the attribute'skeypath is walked and which leaf types have a string form. - Schema — role names of the generated ORM tables are customizable via
SchemaNaming; table names live in the storage configs.
Important
The default PhpSerializer unserializes blobs read from the idempotency tables. The trust
boundary is the table itself: if several services or roles can write to that database, bind a
JSON serializer and keep the operation results JSON-safe.