Mastering HTTP Caching in Laravel with ETag & Last-Modified
In a file-based CMS like this blog, content doesn't change until you push a commit. This makes it the perfect candidate for aggressive HTTP caching strategies. While Laravel provides great server-side caching drivers (Redis, Memcached), client-side caching via HTTP headers is even faster because it prevents the browser from even needing to download the page content.
Today, we'll implement ETag and Last-Modified headers to support conditional requests (304 Not Modified).
Why Conditional Requests?
When a browser visits a page it has seen before, it can send headers asking:
- "Has this file changed since [date]?" (
If-Modified-Since) - "Does this file still match this hash?" (
If-None-Match)
If the answer is "No, it hasn't changed", the server responds with 304 Not Modified and an empty body. The browser loads the content instantly from its local cache.
The Strategy
For a Markdown blog, the "truth" is the file system.
- Last-Modified: The filesystem timestamp (
filemtime) of the.mdfile. - ETag: A hash (md5) of the file content + the layout file (optional but recommended).
Implementation
Laravel makes this surprisingly easy with the setCacheHeaders method or middleware.
1. In the Controller
Here is how you might handle a blog post request:
// App\Http\Controllers\PostController.php
public function show(string $slug, MarkdownPostService $service)
{
$postFile = $service->getFilePath($slug);
if (!file_exists($postFile)) {
abort(404);
}
// 1. Get the last modified timestamp of the markdown file
$lastModified = filemtime($postFile);
// 2. Determine the ETag (hash of content)
$etag = md5_file($postFile);
// 3. Check if the browser's cache is still valid
// This method automatically handles matching the request headers
// against our newly calculated values.
if (request()->headers->get('If-None-Match') === $etag ||
strtotime(request()->headers->get('If-Modified-Since')) >= $lastModified) {
return response(null, 304);
}
// 4. If not 304, render the full page
$post = $service->find($slug);
return response()
->view('blog.show', ['post' => $post])
->setEtag($etag)
->setLastModified(new \DateTime("@$lastModified"))
->setPublic()
// Optional: set a max-age/s-maxage
->setMaxAge(3600);
}
2. Using Middleware
If you want to apply this globally or to a group of routes, Laravel's built-in SetCacheHeaders middleware is powerful, but it relies on key-value pairs. For dynamic file-based checking, a custom middleware is often cleaner.
Create App\Http\Middleware\SmartHttpCache.php:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SmartHttpCache
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Only cache GET/HEAD requests that were successful
if (!$request->isMethodCacheable() || !$response->isSuccessful()) {
return $response;
}
// If the response already has an ETag, assume the controller handled it
if ($response->getEtag()) {
return $response;
}
// Generate ETag from response content
// Note: For large responses, hashing the whole body in middleware
// implies we've already rendered the view.
// The Controller approach is faster (avoids rendering).
$etag = md5($response->getContent());
$response->setEtag($etag);
// This method compares the Request headers with your new Response headers
// and converts the response to a 304 if they match.
$response->isNotModified($request);
return $response;
}
}
Performance Impact
The Controller approach is superior for a flat-file blog.
- Request arrives.
- Check filesystem:
filemtime()is an incredibly fast O(1) syscall. - Compare: If headers match, return 304.
- Avoid Parsing: We skip CommonMark parsing, Blade rendering, and Frontmatter extraction entirely. This is a massive CPU saving.
Gotchas
- Layout Changes: If you change your
bladelayout (e.g., header/footer), the Markdown file hasn't changed, but the HTML output has.- Fix: Add the layout file's mtime to your ETag logic.
- Environment:
isNotModified()might behave differently if you have global middleware modifying headers later.
Conclusion
By implementing ETag and Last-Modified, you turn your dynamic Laravel application into something that behaves almost indistinguishably from a static site generator (SSG) for repeat visitors. It's the best of both worlds: dynamic routing with static performance.