| title | Idempotency |
|---|---|
| description | Prevent duplicate side effects for HTTP routes and command bus commands by storing and replaying the result of the first execution. |
Payment processing, order creation, resource provisioning - any operation where retrying the same request should not produce duplicate side effects. Timeouts, client retries, and accidental double clicks all cause the same problem: the server cannot distinguish a retry from a new request.
The tempest/idempotency package solves this by storing the result of the first execution and replaying it for subsequent requests with the same idempotency key. It supports both HTTP routes and command bus commands.
Add the {bTempest\Idempotency\Attributes\Idempotent} attribute to a controller method. Clients send an Idempotency-Key header with a unique value (typically a UUID). The first request executes normally and caches the response. Subsequent requests with the same key replay the cached response without re-executing the handler.
use Tempest\Router\Post;
use Tempest\Http\Response;
use Tempest\Http\GenericResponse;
use Tempest\Http\Status;
use Tempest\Idempotency\Attributes\Idempotent;
final readonly class OrderController
{
#[Post('/orders')]
#[Idempotent]
public function create(CreateOrderRequest $request): Response
{
$order = $this->orderService->create($request);
return new GenericResponse(
status: Status::CREATED,
body: ['id' => $order->id],
);
}
}The client must include the idempotency key as a header:
POST /orders HTTP/1.1
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{"product": "widget", "quantity": 3}
When a cached response is replayed, the response includes an idempotency-replayed: true header so the client can distinguish replays from original executions.
Idempotency is only supported for POST and PATCH routes. Applying #[Idempotent] to a GET, PUT, DELETE, or other method will throw an {bTempest\Idempotency\Exceptions\IdempotencyMethodWasNotSupported} exception. GET is inherently idempotent, PUT and DELETE are idempotent by definition in HTTP semantics, and only POST and PATCH produce non-idempotent side effects.
Idempotency keys must be scoped per user or client to prevent key collisions across different actors. This is done by implementing the {bTempest\Idempotency\Contracts\IdempotencyScopeResolver} interface and registering it in the container.
The resolve() method receives the current request and must return a string that uniquely identifies the caller - such as a user ID, session ID, or API key:
use Tempest\Http\Request;
use Tempest\Idempotency\Contracts\IdempotencyScopeResolver;
final readonly class UserIdempotencyScopeResolver implements IdempotencyScopeResolver
{
public function __construct(
private AuthManager $auth,
) {}
public function resolve(Request $request): string
{
return (string) $this->auth->currentUser()->id;
}
}:::warning
A scope resolver is required. If no implementation of {bTempest\Idempotency\Contracts\IdempotencyScopeResolver} is registered in the container, the middleware will fail at construction time.
:::
The #[Idempotent] attribute accepts optional parameters to override the global configuration on a per-route basis:
use Tempest\Router\Post;
use Tempest\Http\Response;
use Tempest\Idempotency\Attributes\Idempotent;
final readonly class PaymentController
{
#[Post('/payments')]
#[Idempotent(ttlInSeconds: 172_800, requireKey: true)]
public function charge(ChargeRequest $request): Response
{
// Cached response persists for 48 hours instead of the default 24
}
}| Parameter | Type | Description |
|---|---|---|
ttlInSeconds |
?int |
How long a completed response is cached. Defaults to the config value (86400 / 24 hours). |
pendingTtlInSeconds |
?int |
How long a pending (in-progress) record is considered active. Defaults to the config value (60 seconds). |
requireKey |
?bool |
Whether requests without the idempotency key header should be rejected with a 400 response. Defaults to true. |
header |
?string |
The header name to read the idempotency key from. Defaults to Idempotency-Key. |
When requireKey is set to false, requests without the header bypass idempotency protection entirely and execute normally.
The #[Idempotent] attribute can be applied at the class level to make all routes in a controller idempotent:
use Tempest\Router\Post;
use Tempest\Router\Patch;
use Tempest\Http\Response;
use Tempest\Idempotency\Attributes\Idempotent;
#[Idempotent]
final readonly class ApiOrderController
{
#[Post('/api/orders')]
public function create(CreateOrderRequest $request): Response { /* … */ }
#[Patch('/api/orders/{id}')]
public function update(int $id, UpdateOrderRequest $request): Response { /* … */ }
}The middleware produces different responses depending on the state of the idempotency key:
| Scenario | Status | Description |
|---|---|---|
| No existing record | - | The request executes normally and the response is cached. |
| Completed record, same payload | Original status | The cached response is replayed with an idempotency-replayed: true header. |
| Completed record, different payload | 422 | The key was already used with a different request body. |
| Pending record (in progress) | 409 | Another request with the same key is currently being processed. A retry-after: 1 header is included. |
| Missing key (when required) | 400 | The Idempotency-Key header was not provided. |
The #[Idempotent] attribute is a route decorator that adds {bTempest\Idempotency\Middleware\IdempotencyMiddleware} to the route's middleware stack. The middleware:
- Reads the idempotency key from the request header.
- Computes a fingerprint of the request (method, URI, body, and query parameters).
- Acquires a cache lock to prevent concurrent processing of the same key.
- Checks for an existing record in the idempotency store.
- If no record exists, saves a pending record, executes the handler, and stores the completed response.
- If the handler throws an exception, the pending record is deleted so the request can be retried.
A heartbeat mechanism keeps pending records alive during long-running requests, preventing other processes from incorrectly taking over an operation that is still in progress.
Mark a command class with {bTempest\Idempotency\Attributes\IdempotentCommand} to prevent duplicate dispatches. When the same command is dispatched more than once, the duplicate is silently skipped.
use Tempest\Idempotency\Attributes\IdempotentCommand;
#[IdempotentCommand]
final readonly class ImportInvoicesCommand
{
public function __construct(
public string $vendorId,
public string $month,
) {}
}The handler is defined as usual with #[CommandHandler]:
use Tempest\CommandBus\CommandHandler;
final class ImportInvoicesHandler
{
#[CommandHandler]
public function handleImportInvoices(ImportInvoicesCommand $command): void
{
// Only executes once per unique command payload.
// Duplicate dispatches are silently skipped.
}
}By default, the idempotency key is derived from a fingerprint of the command's properties. Two commands with identical property values produce the same fingerprint and are considered duplicates.
Commands can provide an explicit key by implementing the {bTempest\Idempotency\Contracts\HasIdempotencyKey} interface. This is useful when the deduplication key should be a specific business identifier rather than the full payload:
use Tempest\Idempotency\Attributes\IdempotentCommand;
use Tempest\Idempotency\Contracts\HasIdempotencyKey;
#[IdempotentCommand]
final readonly class ProcessPaymentCommand implements HasIdempotencyKey
{
public function __construct(
public string $paymentId,
public int $amount,
) {}
public function getIdempotencyKey(): string
{
return $this->paymentId;
}
}When using explicit keys, the fingerprint of the command payload is still verified. If the same key is dispatched with a different payload, an {bTempest\Idempotency\Exceptions\IdempotencyKeyWasAlreadyUsed} exception is thrown.
The #[IdempotentCommand] attribute accepts optional TTL parameters:
#[IdempotentCommand(ttlInSeconds: 3600, pendingTtlInSeconds: 30)]
final readonly class ProcessPaymentCommand { /* … */ }| Parameter | Type | Description |
|---|---|---|
ttlInSeconds |
?int |
How long the completed record is cached. Defaults to the config value (86400 / 24 hours). |
pendingTtlInSeconds |
?int |
How long a pending record is considered active. Defaults to the config value (60 seconds). |
The idempotency package is configured by creating an idempotency.config.php file. All settings have sensible defaults:
use Tempest\Idempotency\Config\IdempotencyConfig;
return new IdempotencyConfig(
header: 'Idempotency-Key',
requireKey: true,
ttlInSeconds: 86_400,
pendingTtlInSeconds: 60,
cachePrefix: 'idempotency',
);| Parameter | Default | Description |
|---|---|---|
header |
Idempotency-Key |
The HTTP header name to read the idempotency key from. |
requireKey |
true |
Whether to reject requests that do not include the idempotency key header. |
ttlInSeconds |
86400 (24h) |
How long a completed response is cached. |
pendingTtlInSeconds |
60 |
How long a pending record is considered active before it can be taken over. |
cachePrefix |
idempotency |
Prefix for cache keys in the idempotency store. |
storeClass |
CacheIdempotencyStore |
The {bTempest\Idempotency\Store\IdempotencyStore} implementation to use. |
The default store uses Tempest's cache component. A custom store can be created by implementing the {bTempest\Idempotency\Store\IdempotencyStore} interface and setting the storeClass in the configuration:
use Tempest\Idempotency\Config\IdempotencyConfig;
use App\RedisIdempotencyStore;
return new IdempotencyConfig(
storeClass: RedisIdempotencyStore::class,
);- Windows is not supported. The heartbeat mechanism relies on
pcntl_alarmandpcntl_signal, which are not available on Windows. Attempting to use idempotency on Windows will throw an {bTempest\Idempotency\Exceptions\IdempotencyPlatformWasNotSupported} exception. - Stored responses must be serializable. Response bodies are stored using PHP serialization or JSON encoding. Non-serializable bodies (such as generators or views) are stored as type name strings and will not reproduce the original output on replay.