Skip to main content
Laravel, shipping fast.

This is a cross-cutting rule, so it lives where Chapter 6 put cross-cutting rules: in a middleware, applied to the routes that create things.

// app/Http/Middleware/Idempotent.php
public function handle(
    Request $request,
    Closure $next,
): Response {
    $key = $this->keyFrom($request); // null, or a UUID

    if ($key === null) {
        return $next($request);
    }

    $cacheKey = "idem:{$request->user()->id}:{$key}";
    $lock = Cache::lock("{$cacheKey}:lock", self::LOCK_FOR);

    if (! $lock->get()) {
        return $this->inFlight();
    }

    try {
        return $this->replay($request, $cacheKey)
            ?? $this->run($request, $next, $cacheKey);
    } finally {
        $lock->release();
    }
}
private const LOCK_FOR = 120; // seconds

private function keyFrom(Request $request): ?string
{
    $key = $request->header('Idempotency-Key');

    if ($key !== null && ! Str::isUuid($key)) {
        abort(
            Response::HTTP_BAD_REQUEST,
            __('errors.idempotency_key_invalid'),
        );
    }

    return $key;
}

Read it against the four rules.

The key is a UUID, and anything else is refused. A header is input. An unbounded string from a client shouldn’t become part of a cache key.

The cache key includes the consumer. A key is unique per consumer, not globally. Without the consumer’s ID in it, one consumer could send another’s key and be handed the other’s response.

A lock covers everything. Cache::lock() is atomic: of two requests that arrive together with the same key, exactly one acquires it. The other gets the “in flight” answer, which is rule four. The two minutes are a safety net in case the process dies holding the lock, and they must be longer than the slowest thing the request can do. Chapter 9 adds that up for the provider. (Laravel documentation: Cache › Atomic Locks.)

The saved response is looked up inside the lock. This order is the part that is easy to get wrong. Check the cache first and take the lock second, and a request can miss the cache, wait for the first request to finish and release, then take the lock and do the work again. Inside the lock, either there is a saved response to replay, or this request is the one that runs.

Doing the work and remembering it:

private function run(
    Request $request,
    Closure $next,
    string $cacheKey,
): Response {
    $response = $next($request);

    if ($response->isSuccessful()) {
        Cache::put($cacheKey, [
            'fingerprint' => $this->fingerprint($request),
            'status' => $response->getStatusCode(),
            'body' => $response->getContent(),
        ], now()->addDay());
    }

    return $response;
}

What Gets Remembered

Only success is stored.

A 422 created nothing. If the consumer fixes the body and retries with the same key, it should be allowed to succeed. A 504 means the provider didn’t answer, and remembering that would make every retry fail with the same stale 504 for a day, which is the opposite of what a retry is for.

The rule is to remember the response when the request had an effect. If nothing happened, there is nothing to protect, and the next attempt should run for real.

A day is a judgment. It has to be longer than any consumer’s retry schedule and short enough that the store doesn’t grow without limit. Twenty-four hours is the common choice, and whatever you pick belongs in your documentation.

One kind of response must never be remembered: one that contains a secret. Chapter 5’s token endpoint returns a plain-text token exactly once, and Chapter 4 said to store it nowhere. Put this middleware on that route and the token sits in your cache for a day, ready to be replayed. Idempotency is for the routes that create licenses. It doesn’t belong on every POST.

Replaying, and Refusing

private function replay(
    Request $request,
    string $cacheKey,
): ?Response {
    $saved = Cache::get($cacheKey);

    if ($saved === null) {
        return null;
    }

    $sameRequest = $saved['fingerprint']
        === $this->fingerprint($request);

    abort_unless(
        $sameRequest,
        Response::HTTP_UNPROCESSABLE_ENTITY,
        __('errors.idempotency_key_reused'),
    );

    return response($saved['body'], $saved['status'], [
        'Content-Type' => 'application/json',
        'Idempotent-Replayed' => 'true',
    ]);
}
private function fingerprint(Request $request): string
{
    return hash('sha256', implode('|', [
        $request->method(),
        $request->fullUrl(),
        $request->getContent(),
    ]));
}

The fingerprint is rule three. If a key arrives with a request that differs from the one it was first used with, the consumer is reusing keys across different operations. Replaying the old response would be a lie, and running the new request would break the guarantee. The honest answer is an error that says what happened.

Idempotent-Replayed: true tells the consumer that this response is a copy. It costs one header and it makes a confusing support conversation short.

And rule four:

private function inFlight(): Response
{
    return response()->json([
        'message' => __('errors.request_in_progress'),
        'code' => ErrorCode::RequestInProgress,
    ], Response::HTTP_CONFLICT, ['Retry-After' => 1]);
}

A 409 with Retry-After and its own code says “the first one is still working, ask again in a second.” When the consumer does, it finds the saved response. The code matters here: Chapter 7’s license limit is a 409 too, and that one will never resolve by waiting.

Give the middleware an alias by adding it to the array Chapter 4 started, and apply it to the actions that create licenses:

// bootstrap/app.php, inside withMiddleware()
$middleware->alias([
    'ability' => CheckForAnyAbility::class,
    'idempotent' => Idempotent::class,
]);
// app/Http/Controllers/LicenseController.php
#[Middleware('ability:licenses:read', only: ['index'])]
#[Middleware('ability:licenses:write', except: ['index'])]
#[Middleware('idempotent', only: ['store'])]
class LicenseController
{
    // ...
}

The ability is checked first. A token without licenses:write must be refused before a saved response can be replayed to it.

The audio could not be loaded. Try again in a moment.