Idempotency
Networks fail. A mobile app sends an order, the connection drops before the answer arrives, and the app doesn't know whether the order was saved. If it simply sends it again, the customer may pay twice. Idempotency keys make that retry safe.
How it works
The client creates a unique key, usually a UUID, for each action, and sends it with the request:
POST /api/v1/contact
Idempotency-Key: 7c1e4a52-9f3b-4d38-8a61-2b5f0c9e1d47
Content-Type: application/json
{"name": "Sara", "email": "sara@example.com", "message": "Hello there!"}
The first request runs normally, and its response is stored. A repeat with the same key gets the stored response, with an Idempotent-Replayed: true header, and the handler doesn't run again. When the user starts a new action, the client makes a new key.
Using it on a route
In a new project, the idempotent alias is ready to use:
$router->post('/contact', [ContactController::class, 'store'])
->middleware('throttle:forms', 'idempotent');
Requests without the header are handled normally, so adding idempotent doesn't break existing clients.
The rules
| Situation | Result |
|---|---|
| Same key, same request, first one finished | The stored response, with Idempotent-Replayed: true |
| Same key, same request, first one still running | 409 with Retry-After |
| Same key, different body or query string | 422 |
| The first request failed with an exception or a 5xx response | Not stored, so the client can retry with the same key |
| A key that isn't 1 to 255 visible ASCII characters | 400 |
- Keys are scoped to the signed-in user, or to the client IP for anonymous requests, plus the method and path. Two clients can't collide, even with the same key.
- Responses are kept for 24 hours.
- Only POST requests are checked.
Options
The alias uses file storage under storage/cache/api-idempotency. Build the middleware yourself for other settings:
use FloCMS\Api\Idempotency\{DatabaseIdempotencyStore, FileIdempotencyStore};
use FloCMS\Api\Middleware\IdempotencyMiddleware;
new IdempotencyMiddleware(
new FileIdempotencyStore(ROOT . '/storage/cache/api-idempotency'),
ttlSeconds: 3600, // keep responses for an hour
required: true, // 400 when the header is missing
methods: ['POST', 'PATCH'], // check these methods
);
DatabaseIdempotencyStore keeps records in the api_idempotency table instead of files, which suits sites on several servers. The table is created by php flo migrate.
Cleaning up
Stored responses expire, but their files stay until they're removed. php flo api:gc deletes expired records, and the scheduler runs it daily. Add --database to clean the api_idempotency table too.