matthewbdaly/laravel-etag-middleware
Laravel middleware that automatically adds ETag headers to responses, enabling conditional GET requests and 304 Not Modified replies so clients can cache efficiently and save bandwidth. Simple to register and configure per route.
The package is a single middleware that computes an ETag from the response body, adds the ETag header, and short-circuits to 304 Not Modified when the client's If-None-Match matches.
composer require matthewbdaly/laravel-etag-middleware
web middleware group in app/Http/Kernel.php, so it wraps every browser-facing response:protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
// ...
\Matthewbdaly\LaravelETagMiddleware\Middleware\ETagMiddleware::class,
],
];
curl -i https://myapp.test/posts
# Expect: ETag: "..."
curl -i -H 'If-None-Match: "<the value from above>"' https://myapp.test/posts
# Expect: HTTP/1.1 304 Not Modified, empty body
Where to look first: open vendor/matthewbdaly/laravel-etag-middleware/src/Middleware/ETagMiddleware.php. It is short — confirm the exact FQN, confirm whether it hashes the content or delegates to Symfony's Response::setEtag() / isNotModified(), and check whether it guards on request method. That file tells you everything the README glosses over.
First real use case: a public, cacheable GET endpoint — a blog index, a JSON API list endpoint, a marketing page. Something unauthenticated and identical for every visitor. Do not start with a dashboard.
Scope it deliberately. Registering in the web group tags everything, including responses that vary per user. If you do that, also send Cache-Control: private (or no-cache) so shared caches and CDNs don't hand one user's ETag/body to another. Route-group or per-route registration is safer for mixed applications:
Route::middleware('etag')->group(function () {
Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/{post}', [PostController::class, 'show']);
});
Pair it with an actual response cache. The middleware runs after your controller, so the query still executes and the view still renders — you save bandwidth, not CPU. The pattern that pays off is ETag + a response cache (Laravel's cache middleware, or a full-page cache at the edge). The ETag then gives cheap revalidation on top of an already-cheap response.
JSON APIs are the sweet spot. API responses are typically identical across clients and often large enough that revalidation visibly matters. Combine with Vary headers when the payload depends on Accept, Accept-Encoding, or Accept-Language — the middleware won't add these for you.
Order it last in the group. The ETag must be computed on the final body. Anything that mutates the response after the middleware runs (compression, debug toolbars, minifiers) invalidates the hash. Put ETagMiddleware after cookie/session/auth middleware and before anything that rewrites content.
Testing. Assert both halves of the behaviour, not just header presence:
$first = $this->get('/posts');
$first->assertHeader('ETag');
$this->get('/posts', ['If-None-Match' => $first->headers->get('ETag')])
->assertStatus(304)
->assertNoContent();
Also add a test that a POST is not tagged, and one that two different payloads produce different ETags.
Extension point. If you need conditional behaviour (skip admin routes, weak ETags, custom hashing), subclass the middleware and swap the class in Kernel.php. At ~40 lines it is trivially forkable, which is also the honest answer if the package's Laravel constraint blocks your upgrade.
It only helps if the client revalidates. Browsers do; simple scripts and most HTTP clients don't send If-None-Match unless told to. Don't expect ETags to show up as a win in server-side timing dashboards — the win is bytes over the wire and perceived latency.
304s have no body — downstream middleware may not expect that. Content-rewriting middleware, JSON debuggers, and some error handlers assume a body exists. If you see Trying to access array offset on null or empty-response errors, the ETag middleware is returning early and something later is choking on the empty response.
Authenticated and personalized responses need care. An ETag derived from body content is effectively a fingerprint of the response. On a page containing a CSRF token or a user's name, that fingerprint plus a shared cache is a leak. Either restrict the middleware to public routes or force Cache-Control: private, no-cache.
Debug bars and Telescope. Laravel Debugbar injects HTML into the response. Depending on ordering you'll get an ETag computed on content that differs from what's delivered, causing spurious mismatches or an ETag that changes every request in dev. Expect noise locally; trust the curl check instead.
Don't parse If-None-Match yourself if you fork. It's a comma-separated list, may carry a W/ weak prefix, and * is legal. Symfony's Response::isNotModified() handles all of it — reuse it rather than ===-comparing the header.
The package is unmaintained (last release 2020). Before adopting, check its composer.json against your Laravel version. On Laravel 9/10/11 you may hit constraint conflicts. The fallback is a fifteen-line middleware in app/Http/Middleware:
public function handle($request, Closure $next)
{
$response = $next($request);
if (! $request->isMethodCacheable() || ! $response->isSuccessful()) {
return $response;
}
$response->setEtag(md5($response->getContent()));
$response->isNotModified($request);
return $response;
}
That is essentially the package, and owning it removes the upgrade risk. Given the size, "vendor it and forget it" is a perfectly legitimate assessment outcome.
Compression and weak ETags. If a proxy gzips your response, a strong ETag on the uncompressed body can technically mismatch. In practice browsers handle this fine because the same representation is negotiated, but if you see revalidation failures behind a CDN, switch to a weak tag (W/"...") via setEtag($hash, true).
How can I help you explore Laravel packages today?