grazulex / laravel-api-idempotency
RFC-compliant idempotency support for Laravel APIs - Prevent duplicate operations, ensure safe retries
Fund package maintenance!
Grazulex
paypal.me/strauven
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
pkg:composer/grazulex/laravel-api-idempotency
Requires
- php: ^8.3
- illuminate/cache: ^11.0|^12.0
- illuminate/contracts: ^11.0|^12.0
- illuminate/http: ^11.0|^12.0
- illuminate/routing: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.22
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- phpstan/phpstan: ^2.0
- rector/rector: ^2.0
README
Complete API idempotency lifecycle management for Laravel - Prevent duplicate operations, ensure safe retries
Features
- RFC Draft Compliant - Follows IETF Idempotency-Key Header Draft
- Multiple Storage Drivers - Cache, Redis, Database, or DynamoDB
- Payload Fingerprinting - SHA256 verification prevents key reuse with different data
- Conflict Detection - Handles concurrent requests with wait/reject strategies
- Scoping - Scope keys to user, tenant, IP, or custom resolver
- Zero Configuration - Works out of the box with sensible defaults
- Artisan Commands - Stats, cleanup, and management tools
- Testing Helpers - Fluent testing API with
IdempotencyFake
Requirements
- PHP 8.3+
- Laravel 11.x or 12.x
Installation
composer require grazulex/laravel-api-idempotency
Publish the configuration:
php artisan vendor:publish --tag="api-idempotency-config"
For database driver, publish migrations:
php artisan vendor:publish --tag="api-idempotency-migrations"
php artisan migrate
Quick Start
Apply the middleware to routes:
// routes/api.php Route::post('/payments', [PaymentController::class, 'store']) ->middleware('idempotent'); Route::post('/orders', [OrderController::class, 'store']) ->middleware('idempotent:required'); // Key required
Client request with idempotency key:
curl -X POST https://api.example.com/payments \ -H "Content-Type: application/json" \ -H "Idempotency-Key: pay_abc123_unique_key" \ -d '{"amount": 9999, "currency": "EUR"}'
First request: Executes normally, response cached Same key again: Returns cached response, no re-execution
Response Headers
HTTP/1.1 201 Created Idempotency-Key: pay_abc123_unique_key X-Idempotent-Replayed: false
On replay:
HTTP/1.1 201 Created Idempotency-Key: pay_abc123_unique_key X-Idempotent-Replayed: true X-Original-Request-Time: 2025-01-15T10:30:00+00:00
Middleware Options
// Custom TTL (seconds) ->middleware('idempotent:ttl=172800') // 48 hours // Require key (returns 400 if missing) ->middleware('idempotent:required') // Custom scope ->middleware('idempotent:scope=team') // Combined options ->middleware('idempotent:required,ttl=3600')
PHP Attributes
use Grazulex\ApiIdempotency\Attributes\Idempotent; use Grazulex\ApiIdempotency\Attributes\IdempotentExcept; #[Idempotent] class PaymentController extends Controller { public function store(Request $request) { /* ... */ } #[IdempotentExcept] public function index() { /* ... */ } // Excluded }
Programmatic Usage
use Grazulex\ApiIdempotency\Facades\Idempotency; // Check if already processed if ($cached = Idempotency::get($key)) { return $cached->toResponse(); } // Store manually Idempotency::store($key, response()->json($data, 201)); // Skip caching (e.g., for validation errors) Idempotency::skip();
Key Generation
use Grazulex\ApiIdempotency\Support\IdempotencyKey; // Generate unique key $key = IdempotencyKey::generate(); // "idem_01HQ3K4M..." $key = IdempotencyKey::generate('pay'); // "pay_01HQ3K4M..." // Deterministic key from data $key = IdempotencyKey::fromData([ 'user_id' => 123, 'action' => 'create_payment', ]);
Artisan Commands
# View statistics php artisan idempotency:stats # Cleanup expired keys php artisan idempotency:cleanup # Remove specific key php artisan idempotency:forget pay_abc123 # List recent keys php artisan idempotency:list --limit=20
Events
use Grazulex\ApiIdempotency\Events\IdempotentRequestProcessed; use Grazulex\ApiIdempotency\Events\IdempotentRequestReplayed; use Grazulex\ApiIdempotency\Events\IdempotentConflictDetected; use Grazulex\ApiIdempotency\Events\IdempotentPayloadMismatch;
Configuration
// config/api-idempotency.php return [ 'enabled' => env('API_IDEMPOTENCY_ENABLED', true), 'header' => env('API_IDEMPOTENCY_HEADER', 'Idempotency-Key'), 'key' => [ 'required' => false, 'min_length' => 10, 'max_length' => 255, 'pattern' => '/^[a-zA-Z0-9_-]+$/', ], // Drivers: cache, redis, database, dynamodb 'driver' => env('API_IDEMPOTENCY_DRIVER', 'cache'), 'drivers' => [ 'cache' => [ 'store' => 'default', 'prefix' => 'idempotency:', ], 'redis' => [ 'connection' => 'default', 'prefix' => 'idempotency:', ], 'database' => [ 'connection' => null, 'table' => 'idempotency_keys', ], 'dynamodb' => [ 'table' => 'idempotency_keys', 'region' => 'eu-west-1', ], ], 'ttl' => env('API_IDEMPOTENCY_TTL', 86400), // 24 hours 'conflict' => [ 'strategy' => 'wait', // or 'reject' 'wait_timeout' => 10, 'retry_interval' => 100, ], 'fingerprint' => [ 'enabled' => true, 'algorithm' => 'sha256', 'include_path' => true, 'include_method' => true, 'include_body' => true, 'exclude_fields' => ['timestamp', 'nonce'], ], 'scope' => [ 'enabled' => true, 'resolver' => 'user', // user, tenant, ip, or callable ], 'logging' => [ 'enabled' => true, 'log_hits' => true, 'log_conflicts' => true, ], ];
Testing
use Grazulex\ApiIdempotency\Facades\Idempotency; public function test_idempotency(): void { Idempotency::fake(); // Your test code... Idempotency::assertStored('expected_key'); Idempotency::assertReplayed('expected_key'); Idempotency::assertStoredCount(5); }
Integration testing:
public function test_payment_is_idempotent(): void { $key = 'test_key_' . uniqid(); $payload = ['amount' => 9999]; $response1 = $this->postJson('/api/payments', $payload, [ 'Idempotency-Key' => $key, ]); $response1->assertStatus(201) ->assertHeader('X-Idempotent-Replayed', 'false'); $response2 = $this->postJson('/api/payments', $payload, [ 'Idempotency-Key' => $key, ]); $response2->assertStatus(201) ->assertHeader('X-Idempotent-Replayed', 'true') ->assertJson($response1->json()); }
Quality Tools
# Run tests composer test # Code style (Laravel Pint) composer pint # Static analysis (PHPStan Level 5) composer analyse
Error Responses
| Code | Status | Description |
|---|---|---|
IDEMPOTENCY_KEY_MISSING |
400 | Key required but not provided |
IDEMPOTENCY_KEY_INVALID |
400 | Key format invalid |
IDEMPOTENCY_PAYLOAD_MISMATCH |
422 | Same key, different payload |
IDEMPOTENCY_CONFLICT |
409 | Request in progress with same key |
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security
If you discover any security-related issues, please email security@grazulex.dev instead of using the issue tracker.
Credits
License
The MIT License (MIT). Please see License File for more information.
See Also
- laravel-apiroute - API versioning lifecycle management
- laravel-api-kit - API-only Laravel starter kit