spiral/idempotency

Idempotency guarantees (AtLeastOnce lease / ExactlyOnce inbox) for Spiral Framework operations.

Maintainers

Package info

github.com/spiral/idempotency

pkg:composer/spiral/idempotency

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.2.0 2026-08-05 06:42 UTC

This package is auto-updated.

Last update: 2026-08-11 13:38:21 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

PHP Latest Version on Packagist License Total downloads

Optional packages:

  • cycle/database — the bundled storage driver (lease and inbox tables over a Cycle DBAL connection);
  • spiral/interceptors — the declarative #[Idempotent] attribute via IdempotencyInterceptor (also needs a PSR-17 factory for the HTTP middleware);
  • spiral/queue — the queue/jobs transport (QueueIdempotencyBootloader, QueueKeyMiddleware, QueueRetryMiddleware), making consumed jobs idempotent with Locked → 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 LockedExceptionRetryableLockException → 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:

  1. Return an error response instead of throwing — it is snapshotted and replayed byte-identically, status included. Nothing to configure.

  2. Bind a DomainFailureRendererInterface — keeps the throwing style: HttpOutcomeMiddleware renders 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 the Idempotency-Replay header:

    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. Only FailureKind::Domain failures reach the renderer: Infrastructure and Bug ones stay exceptions, so the key is still released and a retry re-runs. The cacheable predicate still applies, so a rendered 5xx is not cached either. Rows written before the renderer was bound keep replaying as a throw.

  3. Map CachedDomainFailureException::$originalClass in the application exception handler.

Customization

  • Middleware — both pipelines are open: implement ResolutionMiddleware (transport phase, key extraction / outcome mapping) and list it under transports.<name>, or ExecutionMiddleware (domain phase around the operation).
  • Serializer — cached results are serialized with spiral/serializer (PhpSerializer by default); bind your own SerializerInterface to switch, e.g. to JSON.
  • Classifier — bind FailureClassifierInterface to replace the default failure mapping.
  • Domain failure rendering — bind DomainFailureRendererInterface to turn thrown domain failures into cached HTTP responses, so a replay reproduces the same status (see above).
  • Key policy — bind KeyResolverInterface to 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.