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

v1.0.0 2026-02-04 22:14 UTC

This package is auto-updated.

Last update: 2026-02-04 22:52:37 UTC


README

Complete API idempotency lifecycle management for Laravel - Prevent duplicate operations, ensure safe retries

Latest Version on Packagist Tests Total Downloads License

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