rasuvaeff / yii3-idempotency-db
Database-backed idempotency storage for Yii3 APIs
Requires
- php: 8.3 - 8.5
- psr/clock: ^1.0
- rasuvaeff/yii3-idempotency: ^1.0
- yiisoft/db: ^2.0
- yiisoft/db-migration: ^2.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
- yiisoft/cache: ^3.2
- yiisoft/db-sqlite: ^2.0
- yiisoft/injector: ^1.2
- yiisoft/test-support: ^3.0
This package is auto-updated.
Last update: 2026-07-25 13:27:46 UTC
README
Database-backed idempotency storage for Yii3 APIs. Implements
IdempotencyStorage from rasuvaeff/yii3-idempotency with atomic claim
via INSERT (unique PK), response replay, and TTL-based expiration.
Using an AI coding assistant? llms.txt contains a compact API reference you can paste into your prompt.
Requirements
- PHP 8.3+
rasuvaeff/yii3-idempotency^1.0yiisoft/db^2.0yiisoft/db-migration^2.0psr/clock^1.0
Installation
composer require rasuvaeff/yii3-idempotency-db
Usage
Basic setup
use Rasuvaeff\Yii3IdempotencyDb\DbIdempotencyStorage; use Rasuvaeff\Yii3Idempotency\HeaderIdempotencyKeyExtractor; use Rasuvaeff\Yii3Idempotency\IdempotencyMiddleware; $storage = new DbIdempotencyStorage( db: $connection, // yiisoft/db ConnectionInterface clock: $clock, // PSR-20 ClockInterface table: 'idempotency_keys', claimTtlSeconds: 3600, // deadline for in-flight claims (stale-claim recovery) ); $middleware = new IdempotencyMiddleware( keyExtractor: new HeaderIdempotencyKeyExtractor(), storage: $storage, responseFactory: $responseFactory, clock: $clock, ttlSeconds: 3600, );
Run migration
Register the bundled migration by namespace — no vendor paths:
// config/common/di/migration.php use Yiisoft\Db\Migration\Service\MigrationService; return [ MigrationService::class => [ 'setSourceNamespaces()' => [[ 'App\\Migration', 'Rasuvaeff\\Yii3IdempotencyDb\\Migration', ]], ], ];
./yii migrate:up ./yii migrate:down --limit=1
Set the table name in params — config/di.php turns it into an
IdempotencyKeysTableName that reaches the migration and
DbIdempotencyStorage:
// config/common/params.php 'rasuvaeff/yii3-idempotency-db' => [ 'table' => 'my_idempotency_keys', 'table_prefix' => '', // prepended to `table`; e.g. 'rsv_' → rsv_my_idempotency_keys ],
Index names follow the table name (idx_my_idempotency_keys_expires_at), so two installations can share one PostgreSQL schema — index names are unique per schema there, not per table.
Do not configure the migration through the DI container.
M...::class => ['__construct()' => ['table' => ...]]does not work: the migration is built byInjector::make(), which resolves arguments by type and never reads a container definition keyed by the migration's own class. Worse, adding that definition makes the container fatal at build time in every request, because the class is not autoloadable until the migration runner requires it. That recipe was documented in 1.x; it never worked.
Table schema
| Column | Type | Description |
|---|---|---|
key |
VARCHAR(255) PK |
Idempotency key value |
fingerprint |
VARCHAR(64) |
SHA-256 hash of method + path + query + body |
status_code |
SMALLINT |
HTTP response status code |
headers |
TEXT |
JSON-encoded response headers (array<string, list<string>>) |
body |
TEXT |
Response body |
expires_at |
VARCHAR(30) |
Expiration timestamp (UTC, Y-m-d H:i:s) |
claimed |
BOOLEAN |
Whether the key is claimed (in-progress) |
Yii3 integration
The package provides config/di.php and config/params.php for yiisoft/config.
Default params:
// config/params.php return [ 'rasuvaeff/yii3-idempotency-db' => [ 'table' => 'idempotency_keys', 'claimTtlSeconds' => 3600, ], ];
DI wiring binds IdempotencyStorage::class to DbIdempotencyStorage.
How it works
- Claim:
INSERTwith unique PK onkeyandexpires_at = now + claimTtlSeconds. If the insert succeeds, the key is claimed atomically. A duplicate key raises a DB integrity error, whichclaim()converts tofalse; any other DB error propagates. - Store: After the handler completes, the response is upserted into the row
and
claimedis set to0;expires_atbecomes the record TTL deadline. - Load: On a subsequent request with the same key,
load()reads the row. An active claim (claimed = 1, deadline not reached) returnsnullwithout deleting the row — the middleware then fails its ownclaim()and responds 409. A stale claim (deadline passed — crashed process) is deleted and may be re-claimed. A completed record is rehydrated viaIdempotencyRecord::restore()and checked against its TTL; expired records are deleted. - Release: If the handler throws (or returns 5xx),
release()deletes the claim row. - Cleanup:
deleteExpired()removes all rows pastexpires_at(uses theidx_idempotency_expires_atindex) — call it from a cron task.
Security
- Idempotency keys are validated by core (
IdempotencyKey). - Fingerprints are SHA-256 hashes — no raw user input stored beyond the key.
- Response bodies are stored as-is; avoid storing sensitive data without encryption at the application layer.
- All timestamps are stored in UTC — storage behavior does not depend on the PHP default timezone.
Examples
See examples/ for runnable scripts.
Development
make install # composer install make build # full gate (validate + normalize + cs + psalm + test) make cs-fix # fix code style make psalm # static analysis make test # run testo make test-coverage # testo with coverage make mutation # mutation testing make release-check # build + rector + bc-check + mutation
License
BSD-3-Clause. See LICENSE.md.