php
60 lines · 8 steps
Idempotent requests with Laravel middleware
A middleware that caches successful mutating responses so retries with the same key replay the original result instead of running twice.
Explained by
highlit
1<?php
2
3namespace App\Http\Middleware;
4
5use Closure;
6use Illuminate\Http\Request;
7use Illuminate\Support\Facades\Cache;
8use Symfony\Component\HttpFoundation\Response;
9
10class IdempotentRequest
11{
12 private const TTL = 86400;
13
14 public function handle(Request $request, Closure $next): Response
15 {
16 if (! in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
17 return $next($request);
18 }
19
20 $key = $request->header('Idempotency-Key');
21
22 if (! $key) {
23 abort(400, 'Missing Idempotency-Key header.');
24 }
25
26 $cacheKey = "idempotency:{$request->user()?->id}:{$key}";
27
28 if ($cached = Cache::get($cacheKey)) {
29 return response($cached['body'], $cached['status'], $cached['headers'])
30 ->header('Idempotency-Replayed', 'true');
31 }
32
33 $lock = Cache::lock("{$cacheKey}:lock", 10);
34
35 if (! $lock->get()) {
36 abort(409, 'A request with this Idempotency-Key is already in progress.');
37 }
38
39 try {
40 if ($cached = Cache::get($cacheKey)) {
41 return response($cached['body'], $cached['status'], $cached['headers'])
42 ->header('Idempotency-Replayed', 'true');
43 }
44
45 $response = $next($request);
46
47 if ($response->isSuccessful()) {
48 Cache::put($cacheKey, [
49 'status' => $response->getStatusCode(),
50 'body' => $response->getContent(),
51 'headers' => ['Content-Type' => $response->headers->get('Content-Type')],
52 ], self::TTL);
53 }
54
55 return $response;
56 } finally {
57 $lock->release();
58 }
59 }
60}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Idempotency keys let clients safely retry mutating requests without duplicating side effects.
- 2A distributed lock plus a double-check prevents two concurrent requests with the same key from both executing.
- 3Only cache successful responses so failed attempts can be retried cleanly.
Related explainers
java
public class SlidingLogRateLimiter { private final int maxRequests; private final long windowMillis;
How a sliding-log rate limiter works
rate-limiting
concurrency
sliding-window
Advanced
8 steps
go
package theme import ( "fmt"
Per-tenant HTML templates with Gin's renderer
multi-tenancy
concurrency
html-templates
Advanced
8 steps
php
<?php namespace App\Http\Controllers;
Streaming a filtered CSV export in Laravel
streaming
csv-export
query-builder
Intermediate
9 steps
python
import time import threading from enum import Enum from functools import wraps
Building a circuit breaker in Python
circuit-breaker
resilience
decorators
Advanced
7 steps
typescript
import { Injectable } from '@angular/core'; import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http'; import { BehaviorSubject, Observable, throwError } from 'rxjs'; import { catchError, filter, take, switchMap } from 'rxjs/operators';
Refreshing auth tokens in an Angular interceptor
http-interceptor
token-refresh
rxjs
Advanced
8 steps
python
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector from django.core.cache import cache from django.db.models import F from django.http import JsonResponse
Ranked full-text search in Django
full-text-search
debouncing
caching
Advanced
9 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/idempotent-requests-with-laravel-middleware-explained-php-960c/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.