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/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: Use the Redis/Valkey lease backend (RedisLeaseConfig) — AtLeastOnce storage over a Redis-compatible server.
- spiral/cycle-bridge: Integrate the idempotency tables into the Cycle ORM schema (cycle:migrate / cycle:sync) via CycleSchemaBootloader — also pulls cycle/schema-builder.
- 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 IdempotencyInterceptorInterface 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.
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;predis/predis— the Redis/Valkey lease backend (RedisLeaseConfig): AtLeastOnce storage over a Redis-compatible server, with server-side TTL (no GC needed) and atomic Lua CAS;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 gets null (or, with cacheResult: true, a best-effort cached
result: the marker and the result are not committed atomically, so replay is not guaranteed).
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', ), ], ];
Add one line to your existing domain-core interceptor list — reference the
IdempotencyInterceptorInterface alias, not a concrete class:
use Spiral\Idempotency\Interceptor\IdempotencyInterceptorInterface; final class AppBootloader extends DomainBootloader { protected const INTERCEPTORS = [ // ...your existing interceptors (Cycle, Guard, ...) — innermost after auth: IdempotencyInterceptorInterface::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 IdempotencyInterceptorInterface 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
IdempotencyInterceptor 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) |
| No key supplied | 400 Bad Request with a JSON body |
The action responded 5xx |
Not cached: the key is released and a retry re-runs the operation (configurable predicate of HttpOutcomeMiddleware) |
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\IdempotencyInterceptorInterface; $queue->addConsumeInterceptor(IdempotencyInterceptorInterface::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.
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 }
| 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) |
| 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 returns null → the job is ACKed (fire-once); this is the natural home for AtMostOnce |
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.
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) |
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, ...) |
Not cached: the key is released and a retry re-runs (configurable predicate of GrpcOutcomeMiddleware) |
A dedup hit with no cached message (AtMostOnce duplicate, void operation) |
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 DomainFailureMapperInterface 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 RetryableInterface) are never
snapshotted: they stay exceptions so the key is released and a retry re-runs.
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 |
key |
Dot-notation path over the call arguments; 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 |
scope |
Key namespace, see below |
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 |
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\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), ); }
The programmatic path applies no automatic namespacing — compose the final key yourself
(KeyResolverInterface 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 |
Cached as a valid negative outcome, replayed on retry |
| Infrastructure | \Error, or \Exception implementing RetryableInterface |
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 cached domain failure is replayed as CachedDomainFailureException carrying the original class
name and message. For an exact-type replay, implement ReplayableFailureInterface on the
domain exception:
final class PaymentDeclined extends \DomainException implements ReplayableFailureInterface { 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']); } }
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
DomainFailureRendererInterface— 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 DomainFailureRendererInterface { 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 (
DomainFailureRendererInterface::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 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
FailureClassifierInterfaceto replace the default failure mapping. - Domain failure rendering — bind
DomainFailureRendererInterfaceto turn thrown domain failures into cached HTTP responses, so a replay reproduces the same status (see above). - Key policy — bind
KeyResolverInterfaceto change normalization, hashing and hierarchy composition. - Schema — role names of the generated ORM tables are customizable via
SchemaNamingInterface; 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.