Idempotency
Prevent duplicate command processing — three strategies resolved at compile time, result replay on duplicate, and per-route header enforcement.
Idempotency
Command idempotency prevents the same command from being processed more than once. HTTP clients retry on network errors, mobile apps retry on timeout, message queues deliver at-least-once. Without idempotency, retries cause duplicate side effects — two accounts created, two payments charged.
How It Works
IdempotencyKeyPass resolves the strategy for each command at compile time:
Compile time:
IdempotencyKeyPass scans RegisterUser → has #[AsIdempotencyKey] on $requestId
→ stores strategy: {type: 'property', property: 'requestId'}
Runtime (first request):
CommandBus.dispatch(RegisterUser) → reads pre-built map → $command->requestId
→ Redis SET NX → claimed → run handler → result stored in Redis
→ return result
Runtime (duplicate request, same key):
CommandBus.dispatch(RegisterUser) → Redis SET NX → already claimed
→ fetch cached result from Redis → return result (handler never runs)Three strategies are supported: method, property, and none.
Result Replay
On a duplicate request, CommandBus returns the same handler result as the original — the handler is not called again, the database is not touched. The caller receives an identical response:
POST /api/payments (Idempotency-Key: abc-123) ← first
→ handler runs, payment created
→ result stored in Redis under key "abc-123"
→ 201 {"id": "pay_xyz", "status": "created"}
POST /api/payments (Idempotency-Key: abc-123) ← retry 30 seconds later
→ Redis: key already claimed → fetch stored result
→ 201 {"id": "pay_xyz", "status": "created"} ← identical responseThe client cannot tell the difference. This is the correct behaviour for idempotent APIs.
Strategy 1: Property — #[AsIdempotencyKey]
Mark a property on the command with #[AsIdempotencyKey]. The bus reads it directly at runtime — no reflection:
use Vortos\Domain\Command\AbstractCommand;
use Vortos\Domain\Command\AsIdempotencyKey;
final readonly class RegisterUser extends AbstractCommand
{
public function __construct(
#[AsIdempotencyKey]
public string $requestId, // client-generated UUID
public string $email,
public string $name,
) {}
}The client generates a UUID per logical operation and includes it in the request body:
POST /users/register
{
"request_id": "019d4784-7b26-78a6-ba36-3b74ce5ad751",
"email": "alice@example.com",
"name": "Alice"
}Strategy 2: Method Override
Override idempotencyKey() on the command class for custom logic — useful when the key comes from an HTTP header or is derived from multiple fields:
final readonly class ProcessPayment extends AbstractCommand
{
public function __construct(
public string $paymentId,
public int $amount,
public string $currency,
public ?string $idempotencyKey = null,
) {}
public function idempotencyKey(): ?string
{
return $this->idempotencyKey;
}
}// Controller passes the header value into the command
$this->commandBus->dispatch(new ProcessPayment(
paymentId: $dto->paymentId,
amount: $dto->amount,
currency: $dto->currency,
idempotencyKey: $request->headers->get('Idempotency-Key'),
));IdempotencyKeyPass detects the idempotencyKey() override and sets strategy method. Returning null from idempotencyKey() skips the check entirely.
Strategy 3: None (Default)
If neither approach is used, the strategy is none — idempotency is skipped:
final readonly class UpdateUserName extends AbstractCommand
{
public function __construct(
public string $userId,
public string $name,
) {}
// No idempotencyKey() override, no #[AsIdempotencyKey] — strategy: none
}This is fine for commands where duplicates are acceptable — e.g. updating a preference where last-write-wins is correct.
Cannot Use Both Strategies on Same Command
Using both #[AsIdempotencyKey] on a property AND overriding idempotencyKey() throws at compile time:
Command "ProcessPayment" uses both #[AsIdempotencyKey] on property "$paymentId"
AND overrides idempotencyKey() method.
Use one or the other.Enforcing the Header Per Route
Use #[RequiresIdempotencyKey] on a controller to make the Idempotency-Key header mandatory. If the client omits it, the middleware returns HTTP 422 before the controller is called:
use Vortos\Cqrs\Http\Attribute\RequiresIdempotencyKey;
#[AsController]
#[Route('/api/payments', methods: ['POST'])]
#[RequiresAuth]
#[RequiresIdempotencyKey]
final class CreatePaymentController
{
public function __invoke(Request $request): JsonResponse
{
$result = $this->commandBus->dispatch(new ProcessPayment(
idempotencyKey: $request->headers->get('Idempotency-Key'),
// ...
));
return new JsonResponse(['id' => $result->getId()], 201);
}
}The enforced controller list is built at compile time by IdempotencyKeyMiddlewarePass — zero reflection at runtime. The middleware runs at MiddlewareOrder::AUTH - 10, after JWT validation.
Apply #[RequiresIdempotencyKey] to any state-changing endpoint where duplicate execution would be harmful: payments, order creation, account registration.
Idempotency Stores
| Store | Backed by | Survives restart |
|---|---|---|
RedisCommandIdempotencyStore | Redis (PSR-16) | No (unless Redis AOF/RDB enabled) |
InMemoryCommandIdempotencyStore | PHP array | No — use in tests only |
Redis key format:
| Key | Purpose |
|---|---|
vortos_cmd_idempotency_{key} | Claim flag — SET NX, prevents re-execution |
vortos_cmd_result_{key} | Serialized handler result — returned on duplicate |
Both keys share the same TTL. On releaseProcessed() (handler failure), both are deleted so the command can be retried cleanly.
Swap Store
use Vortos\Cqrs\Command\Idempotency\InMemoryCommandIdempotencyStore;
// For tests — no Redis needed
$config->commandBus()->idempotencyStore(InMemoryCommandIdempotencyStore::class);Custom Store (e.g. PostgreSQL for audit trail)
Implement CommandIdempotencyStoreInterface — four required methods plus two for result storage:
use Vortos\Cqrs\Command\Idempotency\CommandIdempotencyStoreInterface;
final class DbalCommandIdempotencyStore implements CommandIdempotencyStoreInterface
{
public function __construct(private \Doctrine\DBAL\Connection $connection) {}
public function tryMarkProcessed(string $idempotencyKey, int $ttl = 86400): bool
{
try {
$this->connection->insert('command_idempotency', [
'key' => $idempotencyKey,
'expires_at' => (new \DateTimeImmutable())->modify("+{$ttl} seconds")->format('Y-m-d H:i:s'),
]);
return true;
} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) {
return false;
}
}
public function storeResult(string $idempotencyKey, mixed $result, int $ttl = 86400): void
{
$this->connection->update('command_idempotency',
['result' => serialize($result)],
['key' => $idempotencyKey],
);
}
public function getResult(string $idempotencyKey): mixed
{
$raw = $this->connection->fetchOne(
'SELECT result FROM command_idempotency WHERE key = ?',
[$idempotencyKey],
);
return $raw ? unserialize($raw) : null;
}
public function wasProcessed(string $idempotencyKey): bool
{
return (bool) $this->connection->fetchOne(
'SELECT 1 FROM command_idempotency WHERE key = ? AND expires_at > NOW()',
[$idempotencyKey],
);
}
public function markProcessed(string $idempotencyKey, int $ttl = 86400): void
{
$this->tryMarkProcessed($idempotencyKey, $ttl);
}
public function releaseProcessed(string $idempotencyKey): void
{
$this->connection->delete('command_idempotency', ['key' => $idempotencyKey]);
}
}$config->commandBus()->idempotencyStore(DbalCommandIdempotencyStore::class);TTL
Idempotency keys expire after the configured TTL (default 24 hours). After expiry, the same key can be reused:
$config->commandBus()->idempotencyTtl(3600); // 1 hour
$config->commandBus()->idempotencyTtl(86400); // 24 hours (default)
$config->commandBus()->idempotencyTtl(604800); // 7 days for financial operationsChoose a TTL long enough to cover your client's retry window. For payment operations, use at least 7 days — payment processors can retry for days on network failures.
After TTL Expires
Once the TTL expires, the claim key and result are gone from Redis. A duplicate request with the same idempotency key will re-execute the handler. If the domain state already exists (e.g. user with that email), the domain itself will reject the duplicate naturally. This is the correct fallback — the 24-hour window covers all realistic retry scenarios.