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.
composer require spiral/idempotencyOptional 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).
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).
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.
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).
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.
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.
#[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 |
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.
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).
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']);
}
}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.
- 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.