Laravel 16 min de lecture

Endpoints idempotents en Laravel: clés d’idempotence, verrous et réponses rejouées

#laravel#filamentphp

Guide endpoints HTTP idempotents Laravel: clé d’idempotence, hash de requête, verrou cache, réponses rejouées. Prêt à coder paiements, commandes, webhooks.

Endpoints idempotents en Laravel: clés d’idempotence, verrous et réponses rejouées

Dans ce tutoriel, vous allez implémenter de bout en bout des endpoints HTTP réellement idempotents dans une application Laravel. Vous apprendrez à accepter une clé d’idempotence, à calculer une empreinte de la requête, à utiliser un verrou atomique côté cache, puis à persister et rejouer la réponse initiale de manière sûre et contrôlée.

Objectif

L’objectif est de livrer un guide pas à pas pour bâtir un middleware d’idempotence prêt à l’emploi en Laravel. Concrètement, l’endpoint acceptera un en-tête Idempotency-Key, calculera un hash stable du contenu de la requête, prendra un verrou atomique via le cache (Redis recommandé), exécutera la logique métier une seule fois, puis stockera et rejouera exactement la même réponse lors des tentatives suivantes. L’ensemble couvre la création du schéma de base de données, le modèle Eloquent, l’utilitaire de hash, le middleware, l’enregistrement dans le Kernel et un exemple concret sur un endpoint de paiement.

Objectif et périmètre

Ce mécanisme s’applique aux requêtes POST, PUT, PATCH et DELETE sensibles, par exemple la création de paiements, de commandes ou le traitement de webhooks, afin d’éviter les doublons provoqués par des retries réseau, des rechargements de page ou des doubles clics. Le livrable final comprend un middleware Idempotency-Key, une table persistante des requêtes, un verrou via Cache::lock pour la concurrence, et la réutilisation de la réponse initiale pour les appels répétés avec la même clé et le même payload.

Créer la table d’idempotence (migration + modèle)

Commencez par générer la migration pour stocker les clés et les réponses. Utilisez la commande artisan suivante:

php artisan make:migration create_idempotency_keys_table

Éditez ensuite la migration pour définir un schéma minimal, incluant une contrainte d’unicité sur la paire (key, scope), des index pour expires_at et request_hash, et de quoi conserver l’état, le code HTTP, des en-têtes filtrés et le corps JSON de la réponse.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('idempotency_keys', function (Blueprint $table) {
            $table->id();
            $table->string('key', 128);
            $table->string('scope', 191); // ex: "user:42|payments.store"
            $table->string('request_hash', 64);
            $table->enum('status', ['pending', 'succeeded', 'failed'])->default('pending');
            $table->smallInteger('status_code')->nullable();
            $table->mediumText('response_json')->nullable(); // JSON encodé
            $table->json('headers_json')->nullable();        // en-têtes autorisés à rejouer
            $table->timestamp('expires_at')->index();
            $table->timestamps();

            $table->unique(['key', 'scope']);
            $table->index('request_hash');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('idempotency_keys');
    }
};

Ajoutez ensuite un modèle Eloquent dédié avec des casts JSON, une stratégie de purge (pruning) basée sur expires_at, et une durée de rétention configurable. Créez un fichier de configuration pour contrôler le TTL et la taille maximale du corps.

php artisan make:model IdempotencyKey
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;

class IdempotencyKey extends Model
{
    use Prunable;

    protected $table = 'idempotency_keys';

    protected $guarded = [];

    protected $casts = [
        'headers_json' => 'array',
        'response_json' => 'array',
        'expires_at' => 'datetime',
    ];

    public function prunable()
    {
        return static::where('expires_at', '<', now());
    }
}

Créez ensuite une configuration dédiée.

# config/idempotency.php
<?php

return [
    // Durée de conservation des entrées (en secondes)
    'ttl' => env('IDEMPOTENCY_TTL_SECONDS', 86400), // 24h par défaut

    // Taille maximale du corps rejoué (en octets)
    'max_body_bytes' => env('IDEMPOTENCY_MAX_BODY_BYTES', 65536), // 64KB

    // Liste blanche d'en-têtes à rejouer
    'allowed_headers' => [
        'Content-Type',
        'ETag',
        'Location',
        'Cache-Control',
    ],
];

Dans votre Console Kernel, planifiez la purge quotidienne afin d’éviter la croissance illimitée de la table. Cela s’appuie sur la méthode prunable() du modèle.

// app/Console/Kernel.php

protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
{
    $schedule->command('model:prune', [
        '--model' => \App\Models\IdempotencyKey::class,
    ])->daily();
}

En production, un TTL de 24 à 48 heures est généralement adapté aux cas de paiements; ajustez selon votre domaine et vos SLA.

Définir une empreinte stable de requête (request_hash)

Le hash de requête permet de s’assurer que la même clé d’idempotence ne rejoue la réponse que si la requête est réellement identique sur le fond. L’empreinte englobe la méthode HTTP, le chemin, la query string triée et un corps JSON canonisé. Canoniser signifie trier les clés de manière déterministe, sérialiser de façon stable et exclure les champs volatils comme un timestamp client.

Créez un petit service d’aide qui transforme un tableau JSON en représentation canonique et calcule le hash SHA-256 sur la concaténation des éléments stables de la requête.

<?php
// app/Support/Idempotency.php

namespace App\Support;

use Illuminate\Http\Request;
use Illuminate\Support\Arr;

class Idempotency
{
    /**
     * Supprime les clés volatiles et trie récursivement les clés
     * pour obtenir une représentation JSON stable.
     */
    public static function canonicalizeJson(array $data, array $excludeKeys = ['timestamp', 'ts', 'nonce']): string
    {
        $filtered = self::excludeKeysRecursive($data, $excludeKeys);
        $sorted = self::sortKeysRecursive($filtered);
        return json_encode($sorted, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    }

    protected static function excludeKeysRecursive($value, array $excludeKeys)
    {
        if (!is_array($value)) {
            return $value;
        }

        $result = [];
        foreach ($value as $k => $v) {
            if (in_array((string)$k, $excludeKeys, true)) {
                continue;
            }
            $result[$k] = self::excludeKeysRecursive($v, $excludeKeys);
        }
        return $result;
    }

    protected static function sortKeysRecursive($value)
    {
        if (!is_array($value)) {
            return $value;
        }

        // Pour les tableaux associatifs, trier par clé; pour les listes, trier chaque item récursivement
        if (Arr::isAssoc($value)) {
            ksort($value);
            foreach ($value as $k => $v) {
                $value[$k] = self::sortKeysRecursive($v);
            }
            return $value;
        }

        return array_map(fn($v) => self::sortKeysRecursive($v), $value);
    }

    /**
     * Construit un hash stable pour une requête HTTP.
     */
    public static function requestHash(Request $request, array $excludeBodyKeys = ['timestamp', 'ts', 'nonce']): string
    {
        $method = strtoupper($request->getMethod());
        $path = '/' . ltrim($request->path(), '/');

        $queryArray = $request->query();
        ksort($queryArray);
        $queryCanonical = self::canonicalizeJson($queryArray, []); // pas d'exclusion en query

        $body = $request->all();
        $bodyCanonical = self::canonicalizeJson(is_array($body) ? $body : [], $excludeBodyKeys);

        $canonical = implode('|', [$method, $path, $queryCanonical, $bodyCanonical]);

        return hash('sha256', $canonical);
    }
}

Cette approche signifie qu’une requête avec la même clé mais un payload différent produira un hash distinct et sera rejetée avec un code explicite, plutôt que de rejouer à tort une réponse incompatible.

Middleware Idempotency-Key: flux de contrôle

Le middleware est le cœur du dispositif. Il valide l’en-tête, construit un scope pour éviter les collisions entre utilisateurs ou routes, calcule le request_hash, gère les cas pending/succeeded/failed, prend un verrou atomique si nécessaire, exécute la logique métier dans une transaction, sérialise une réponse rejouable, puis la renvoie immédiatement aux tentatives suivantes avec la même clé et la même empreinte.

Créez le middleware:

php artisan make:middleware IdempotencyMiddleware

Implémentez la logique suivante:

<?php
// app/Http/Middleware/IdempotencyMiddleware.php

namespace App\Http\Middleware;

use App\Models\IdempotencyKey;
use App\Support\Idempotency;
use Closure;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;

class IdempotencyMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        // 1) Exiger l'en-tête sur les routes où le middleware est appliqué
        $key = $request->headers->get('Idempotency-Key');

        if (!$key) {
            return response()->json([
                'message' => 'Missing Idempotency-Key header',
            ], 400);
        }

        // 2) Valider le format: 20-128 caractères alphanumériques, - et _
        if (!preg_match('/^[A-Za-z0-9\-\_]{20,128}$/', $key)) {
            return response()->json([
                'message' => 'Invalid Idempotency-Key format',
            ], 400);
        }

        // 3) Construire un scope pour isoler par utilisateur/tenant et route
        $routeName = optional($request->route())->getName() ?? $request->path();
        $userPart = Auth::check() ? 'user:' . Auth::id() : 'guest:' . $request->ip();
        $scope = $userPart . '|' . $routeName;

        // 4) Calculer l'empreinte de la requête
        $requestHash = Idempotency::requestHash($request);

        // 5) Chercher une entrée existante (par clé + scope)
        $existing = IdempotencyKey::where('key', $key)
            ->where('scope', $scope)
            ->first();

        if ($existing && $existing->status === 'succeeded') {
            if (hash_equals($existing->request_hash, $requestHash)) {
                // 6) Rejouer immédiatement la réponse stockée
                $headers = collect($existing->headers_json ?? [])
                    ->only(config('idempotency.allowed_headers'))
                    ->toArray();

                // Indiquer au client que c'est une réponse rejouée
                $headers['Idempotent-Replay'] = 'true';

                return response()
                    ->json($existing->response_json ?? [], (int) $existing->status_code, $headers);
            }

            return response()->json([
                'message' => 'Idempotency-Key reused with different payload',
                'hint' => 'Provide a new Idempotency-Key for a different request body',
            ], 422);
        }

        if ($existing && $existing->status === 'pending') {
            // 7) Une exécution est en cours
            return response()->json([
                'message' => 'Request is still being processed',
            ], 409, ['Retry-After' => '2']);
        }

        // 8) Tenter d'acquérir un verrou atomique pour éviter les courses
        $lockKey = "idem:{$scope}:{$key}";
        $lock = Cache::lock($lockKey, 10); // TTL de verrou: 10s

        if (!$lock->get()) {
            return response()->json([
                'message' => 'Another request with the same Idempotency-Key is in progress',
            ], 409, ['Retry-After' => '2']);
        }

        try {
            // 9) Créer/mettre à jour l'entrée en pending, avec expiration
            $record = IdempotencyKey::updateOrCreate(
                ['key' => $key, 'scope' => $scope],
                [
                    'request_hash' => $requestHash,
                    'status' => 'pending',
                    'expires_at' => now()->addSeconds((int) config('idempotency.ttl')),
                ]
            );

            // 10) Exécuter la requête dans une transaction DB si elle modifie des ressources
            $response = DB::transaction(fn() => $next($request));

            // 11) Extraire une réponse rejouable
            $replay = $this->extractReplayable($response);

            // 12) Limiter la taille et neutraliser si trop gros
            $payloadJson = json_encode($replay['body'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
            $maxBytes = (int) config('idempotency.max_body_bytes');

            if (strlen($payloadJson) > $maxBytes) {
                $payloadJson = json_encode([
                    'truncated' => true,
                    'message' => 'Body truncated due to size limit',
                    'sha256' => hash('sha256', (string) $response->getContent()),
                ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
            }

            // 13) Persister le résultat
            $record->status = 'succeeded';
            $record->status_code = $replay['status'];
            $record->headers_json = $replay['headers'];
            $record->response_json = json_decode($payloadJson, true);
            $record->save();

            return $response;
        } catch (\Throwable $e) {
            // 14) En cas d’erreur, marquer l'entrée en failed
            if (isset($record)) {
                $record->status = 'failed';
                $record->save();
            }

            throw $e;
        } finally {
            // 15) Toujours relâcher le verrou
            $lock->release();
        }
    }

    /**
     * Conserve seulement des éléments rejouables: code, JSON, en-têtes whitelistes.
     */
    protected function extractReplayable($response): array
    {
        $status = method_exists($response, 'getStatusCode')
            ? $response->getStatusCode()
            : SymfonyResponse::HTTP_OK;

        $contentType = $response->headers->get('Content-Type');

        $allowedHeaders = collect(config('idempotency.allowed_headers'))
            ->filter(fn($h) => $response->headers->has($h))
            ->mapWithKeys(fn($h) => [$h => $response->headers->get($h)])
            ->toArray();

        // Si c'est du JSON, garder le body tel quel; sinon, créer une forme sûre
        if ($contentType && str_contains($contentType, 'application/json')) {
            $body = json_decode((string) $response->getContent(), true);
            if (!is_array($body)) {
                $body = ['value' => (string) $response->getContent()];
            }
        } else {
            $body = [
                'status' => $status,
                'message' => 'Non-JSON response rewrapped for idempotent replay',
            ];

            // Tenter d'extraire un Location pour conserver un pointeur vers la ressource
            if ($response->headers->has('Location')) {
                $body['location'] = $response->headers->get('Location');
            }
        }

        return [
            'status' => $status,
            'headers' => $allowedHeaders,
            'body' => $body,
        ];
    }
}

Pour tirer parti de verrous atomiques, activez un store Redis pour le cache. Avec Laravel Sail ou une stack Redis, vérifiez que le driver par défaut supporte lock():

CACHE_STORE=redis

Si votre cache actuel ne supporte pas les verrous (ex: array, file), migrez vers Redis ou Memcached. En ultime recours, vous pouvez simuler un mutex via la colonne status='pending' et la contrainte unique(key, scope), mais un lock cache reste plus robuste sous forte concurrence.

Implémentation pas à pas (code et intégration)

Enregistrez le middleware dans votre Kernel pour l’utiliser sur des routes mutables uniquement.

// app/Http/Kernel.php

protected $routeMiddleware = [
    // ...
    'idempotency' => \App\Http\Middleware\IdempotencyMiddleware::class,
];

Vous pouvez ensuite l’attacher à un groupe de routes ciblant POST/PUT/PATCH/DELETE. Par exemple:

// routes/api.php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\PaymentController;

Route::middleware(['throttle:idempotent'])->group(function () {
    Route::post('/payments', [PaymentController::class, 'store'])
        ->name('payments.store')
        ->middleware('idempotency');
});

Ajoutez un limiteur de débit spécifique qui prend en compte la clé d’idempotence pour éviter les tempêtes de retry.

// app/Providers/RouteServiceProvider.php

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    RateLimiter::for('idempotent', function (Request $request) {
        $key = $request->header('Idempotency-Key') ?? 'no-key';
        return Limit::perMinute(60)->by($key . '|' . $request->ip());
    });

    // ...
}

Si vous ne pouvez pas activer un cache avec locks, un fallback minimal consiste à s’appuyer sur la contrainte unique et le status pending comme garde-basique: une tentative d’insert concurrente lève une exception de contrainte, que vous transformez en 409. Ce pattern est tolérable en faible charge, mais n’offre pas les garanties propres aux verrous atomiques sous très forte concurrence.

Modéliser la réponse rejouée de manière sûre

L’idempotence ne signifie pas tout stocker. Conservez uniquement ce qui est réellement nécessaire à rejouer la réponse:

  • Stockez le code HTTP (200/201/202/204…), un sous-ensemble d’en-têtes inoffensifs comme Content-Type, ETag, Location et Cache-Control, et un corps JSON.
  • Limitez la taille du body persistant à 64KB maximum pour éviter de gonfler la base. Si la réponse dépasse cette limite, tronquez-la et ajoutez un hash du contenu original pour diagnostiquer.
  • Si l’endpoint renvoie des formats non-JSON (fichier, HTML), transformez la réponse en une forme JSON canonique contenant au minimum le code, un message et éventuellement une Location ou un identifiant de ressource. Un client idempotent doit surtout savoir si l’opération a réussi et sur quelle ressource.

Dans le middleware ci-dessus, la méthode extractReplayable applique cette politique et n’enregistre jamais d’informations sensibles telles que des tokens d’accès.

Exemple concret: POST /api/payments

Créez un endpoint de paiement simple pour illustrer le flux: validation, création en base dans une transaction, et réponse 201 avec l’identifiant et l’état.

<?php
// app/Http/Controllers/PaymentController.php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class PaymentController extends Controller
{
    public function store(Request $request)
    {
        $data = $request->validate([
            'amount' => ['required', 'integer', 'min:1'],
            'currency' => ['required', 'string', 'size:3'],
            'source' => ['required', 'string', 'max:100'],
        ]);

        // Exemple ultra simplifié d'écriture métier
        $payment = DB::transaction(function () use ($data) {
            $id = (string) Str::uuid();

            DB::table('payments')->insert([
                'id' => $id,
                'amount' => $data['amount'],
                'currency' => strtoupper($data['currency']),
                'source' => $data['source'],
                'status' => 'succeeded',
                'created_at' => now(),
                'updated_at' => now(),
            ]);

            DB::table('ledgers')->insert([
                'payment_id' => $id,
                'delta' => -$data['amount'],
                'currency' => strtoupper($data['currency']),
                'created_at' => now(),
                'updated_at' => now(),
            ]);

            return [
                'id' => $id,
                'status' => 'succeeded',
            ];
        });

        return response()->json($payment, 201, [
            'Location' => route('payments.show', ['payment' => $payment['id']]),
        ]);
    }
}

Pour tester le comportement idempotent, exécutez deux requêtes avec la même clé et la même charge utile. La première crée le paiement, la seconde rejoue la réponse.

curl -X POST http://localhost/api/payments \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pay_20240101_abcdefghijklmnopqr" \
  -d '{"amount": 5000, "currency": "eur", "source": "card_123"}'

Relancez immédiatement la même requête avec exactement les mêmes données et la même clé Idempotency-Key. Vous devriez recevoir une 201 avec un en-tête Idempotent-Replay: true et un corps identique à la première réponse. Si vous réutilisez la même clé mais modifiez le payload (par exemple amount: 6000), le middleware renverra un 422 expliquant que la clé a été réutilisée avec un payload différent. En cas d’appels parallèles, un seul passera sous verrou; les autres recevront une 409 avec Retry-After le temps que l’exécution se termine.

Tests: fonctionnels et concurrence

Vérifiez le flux nominal et les scénarios d’erreurs avec des tests de fonctionnalité. Voici une série de tests illustratifs utilisant PHPUnit dans Laravel.

<?php
// tests/Feature/IdempotencyTest.php

namespace Tests\Feature;

use App\Models\IdempotencyKey;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;

class IdempotencyTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        // Tables fictives de l'exemple
        $this->app['db']->connection()->getSchemaBuilder()->create('payments', function ($table) {
            $table->uuid('id')->primary();
            $table->integer('amount');
            $table->string('currency', 3);
            $table->string('source');
            $table->string('status');
            $table->timestamps();
        });

        $this->app['db']->connection()->getSchemaBuilder()->create('ledgers', function ($table) {
            $table->id();
            $table->uuid('payment_id');
            $table->integer('delta');
            $table->string('currency', 3);
            $table->timestamps();
        });
    }

    public function test_happy_path_replay_same_response(): void
    {
        $key = 'TESTKEY_ABCDEFGHIJKLMNOPQ';

        $payload = [
            'amount' => 5000,
            'currency' => 'eur',
            'source' => 'card_123',
        ];

        $r1 = $this->withHeaders(['Idempotency-Key' => $key])
            ->postJson(route('payments.store'), $payload);

        $r1->assertCreated();
        $body1 = $r1->json();

        $r2 = $this->withHeaders(['Idempotency-Key' => $key])
            ->postJson(route('payments.store'), $payload);

        $r2->assertCreated();
        $r2->assertHeader('Idempotent-Replay', 'true');
        $r2->assertJson($body1);
    }

    public function test_same_key_different_payload_is_422(): void
    {
        $key = 'TESTKEY_ABCDEFGHIJKLMNOPQ';

        $p1 = ['amount' => 5000, 'currency' => 'eur', 'source' => 'card_123'];
        $p2 = ['amount' => 6000, 'currency' => 'eur', 'source' => 'card_123'];

        $this->withHeaders(['Idempotency-Key' => $key])
            ->postJson(route('payments.store'), $p1)
            ->assertCreated();

        $this->withHeaders(['Idempotency-Key' => $key])
            ->postJson(route('payments.store'), $p2)
            ->assertStatus(422);
    }

    public function test_pending_entry_returns_409(): void
    {
        $key = 'TESTKEY_ABCDEFGHIJKLMNOPQ';
        $scope = 'guest:127.0.0.1|payments.store';

        IdempotencyKey::create([
            'key' => $key,
            'scope' => $scope,
            'request_hash' => str_repeat('a', 64),
            'status' => 'pending',
            'expires_at' => now()->addHour(),
        ]);

        $this->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])
            ->withHeaders(['Idempotency-Key' => $key])
            ->postJson(route('payments.store'), [
                'amount' => 5000, 'currency' => 'eur', 'source' => 'card_123'
            ])
            ->assertStatus(409)
            ->assertHeader('Retry-After', '2');
    }

    public function test_concurrent_lock_returns_409_for_second_request(): void
    {
        $key = 'TESTKEY_ABCDEFGHIJKLMNOPQ';
        $scope = 'guest:127.0.0.1|payments.store';
        $lockKey = "idem:{$scope}:{$key}";

        $lock = Cache::lock($lockKey, 10);
        $this->assertTrue($lock->get());

        $this->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])
            ->withHeaders(['Idempotency-Key' => $key])
            ->postJson(route('payments.store'), [
                'amount' => 5000, 'currency' => 'eur', 'source' => 'card_123'
            ])
            ->assertStatus(409);

        $lock->release();
    }

    public function test_pruning_removes_expired_entries(): void
    {
        Carbon::setTestNow(now());

        IdempotencyKey::create([
            'key' => 'OLDKEY_ABCDEFGHIJKLMNOPQ',
            'scope' => 'guest:127.0.0.1|payments.store',
            'request_hash' => str_repeat('b', 64),
            'status' => 'succeeded',
            'expires_at' => now()->subMinute(),
            'status_code' => 201,
            'headers_json' => ['Content-Type' => 'application/json'],
            'response_json' => ['ok' => true],
        ]);

        $this->assertDatabaseCount('idempotency_keys', 1);

        Artisan::call('model:prune', ['--model' => IdempotencyKey::class]);

        $this->assertDatabaseCount('idempotency_keys', 0);
    }
}

Ces tests couvrent le scénario heureux, la réutilisation de clé avec payload différent, une entrée pending déjà présente et un conflit de verrouillage simulé. Ils montrent également comment pruner les lignes expirées.

Opérations: purge, observabilité, limites

En production, automatisez la purge via le Scheduler quotidien défini précédemment et surveillez l’usage. Il est utile de journaliser le nombre d’exécutions réellement effectuées versus les replays, les conflits de verrou, et la durée passée sous verrou. Une instrumentation simple dans le middleware peut suffire, par exemple en ajoutant Log::info à chaque branche (succeeded replay, pending, conflict, executed) avec la route, l’utilisateur et la durée.

Adaptez la durée de rétention au domaine. Pour des paiements et commandes, 24 à 48 heures couvrent généralement les retries clients et les instabilités réseau. Plus long augmente la taille de la table; plus court expliquez-le aux clients, qui devront rejouer avec une nouvelle clé si l’entrée a expiré.

Enfin, l’interaction avec le rate limiting est critique. Combinez un limiteur basé sur l’Idempotency-Key (et l’IP) avec des Retry-After explicites. Cela calmera les tempêtes de retries côté client sans compromettre la fiabilité.

Check-list anti-pièges

Utilisez de préférence Redis pour des verrous atomiques robustes via Cache::lock. Évitez de partager les clés d’idempotence entre utilisateurs ou routes: construisez un scope qui inclut l’identifiant utilisateur ou le tenant et le nom de la route pour empêcher les réutilisations malicieuses d’une clé. Comparez toujours l’empreinte de la requête; ne rejouez jamais une réponse si la même clé arrive avec un payload différent, et renvoyez plutôt un 422 clair. Quand vous rejouez, ne renvoyez que des en-têtes autorisés et limitez la taille du corps; n’écrivez ni ne rejouez d’informations sensibles. Enfin, renvoyez le même code HTTP que lors de la première exécution (201, 200, 204…) pour ne pas surprendre les clients, et documentez précisément vos attentes autour d’Idempotency-Key, des codes 409/422/425 éventuels et de la durée de rétention.

Checklist

Avant de publier, relisez le middleware et ses comportements de bord, exécutez les snippets artisan et les tests pour valider le flux nominal et la concurrence, puis documentez l’API à destination des clients en incluant des exemples de requêtes avec l’en-tête Idempotency-Key, la politique de Retry-After et le TTL de rétention. Une fois validé en staging, déployez en production avec Redis activé pour les verrous et le Scheduler configuré pour la purge.

Ressources

La documentation de Laravel sur les locks de cache explique le fonctionnement de Cache::lock et les backends compatibles: https://laravel.com/docs/cache#atomic-locks. Le trait Prunable et la commande model:prune sont décrits ici: https://laravel.com/docs/eloquent#pruning-models. Pour le rate limiting, consultez https://laravel.com/docs/rate-limiting. Si vous utilisez Redis, la page sur la configuration des stores de cache est utile: https://laravel.com/docs/cache#configuration. Pour la philosophie d’idempotence, la documentation Stripe est une excellente référence conceptuelle: https://stripe.com/docs/idempotency.

Conclusion

Avec un schéma persistant, une empreinte de requête stable, un verrou atomique via le cache et un middleware soigné, vous rendez vos endpoints critiques réellement idempotents. Cette approche élimine les doublons causés par les retries et protège vos opérations métiers sous concurrence. En ajoutant des tests, des limites de taille, des en-têtes contrôlés et un TTL de rétention, vous obtenez une solution fiable, observable et prête pour la production.