Caching your Laravel API with ETag and Conditional Requests

Caching your Laravel API with ETag and Conditional Requests

When writing an application with a decoupled front- and backend, you'll have to start considering the requests your frontend client makes to the API. Fetching data again from the backend, even when you only want to verify that your frontend's cache is up to date can quickly add up. To combat this, you can make use of the ETag header and conditional requests.

In this blogpost I'll give a quick summary on what ETag, If-None-Match and If-Match headers do, and then go over how I approached implementing this into our package which would be the quickest way of implementing it in your own application.

Skip straight to the implementation

What

Let's start with the header that is at the core of all of this, the ETag header. This header is meant to be a value that represents the response body in the exact state it is in. In many cases, the ETag value will be a hash of the content, since this is easiest to generate and guarantees a unique identifier for the response data.

To make the ETag header useful, we'll have to use conditional requests. The first of two we'll go over is the If-None-Match header. This is a request header, and is meant to be used on GET requests. When your backend receives this header, it should compare it's value to the value of the current content. If these values match, nothing but a 304 status code should be returned, resulting in a response that is tiny compared to fetching the entire resource. The implementation of this is dead easy: if your first GET request to a resource gave you a response with the ETag header set, your browser will automatically set the If-None-Match header on subsequent requests to the resource.

This means that if you simply implement the ETag and If-None-Match on your backend, the amount of data transferred from your API to your frontend can be reduced by quite a bit.

The second conditional request uses the If-Match header. This is used to prevent mid-air collisions. Simply put, if we want to update data in the backend, but our frontend data is outdated, the update should be halted and our frontend should be notified. This works in a similar way as If-None-Match. After fetching a resource and obtaining the resource's ETag value, you can make a PATCH request to this resource and set the If-Match value equal to the ETag you previously received. The backend will then check if the ETag value of the resource currently available on the server matches the one you send. If these match, your update will be allowed. If there is no match, 412 will be returned, letting the frontend know that the condition has not been met.

How

If all you want to do is use conditional requests in Laravel, you can simply run:

$ composer require werk365/etagconditionals

After which you can add the etag middleware to your route and you'll be good to go. If you're curious about how the middlewares work or how you could implement this without using our package, keep reading!

SetEtag Middleware

As you might have guessed, implementing this one was the most simple of the middlewares. Laravel actually provides an option to set the ETag header through the SetCacheHeaders middleware, but it does not support HEAD requests. The contents of the SetEtag middleware looks something like this:

    public function handle(Request $request, Closure $next)
    {
        // Handle request
        $method = $request->getMethod();

        // Support using HEAD method for checking If-None-Match
        if ($request->isMethod('HEAD')) {
            $request->setMethod('GET');
        }

        //Handle response
        $response = $next($request);

        // Setting etag
        $etag = md5($response->getContent());
        $response->setEtag($etag);

        $request->setMethod($method);

        return $response;
    }

The first thing we do is getting the method of the request in case we want to modify it. Then if we're dealing with a HEAD request, we'll change it to a GET request to make sure the content is loaded and a hash can be made. After this, we skip to the response where we'll take the response body and hash it using the md5() function. We'll set this hash as the ETag header and make sure the original request method is set back before returning the response.

IfNoneMatch Middleware

This is another relatively straight forward one. Let's view the code first:

    public function handle(Request $request, Closure $next)
    {
        // Handle request
        $method = $request->getMethod();

        // Support using HEAD method for checking If-None-Match
        if ($request->isMethod('HEAD')) {
            $request->setMethod('GET');
        }

        //Handle response
        $response = $next($request);

        $etag = '"'.md5($response->getContent()).'"';
        $noneMatch = $request->getETags();

        if (in_array($etag, $noneMatch)) {
            $response->setNotModified();
        }

        $request->setMethod($method);

        return $response;
    }

The start of this looks familiar to the SetEtag middleware, we'll ensure we can handle HEAD requests again, and we generate a hash based on the response content. Note that in this case we'll add double quotes around the hash. ETag headers are supposed to be wrapped in double quotes, and the setEtag() method wrapped our hash automatically in the SetEtag middleware. After we have the hash, we can simply compare it to the If-None-Match header. Since this header can actually contain any number of hashes, and the getETags() method will return them as an array, we'll simply check if our newly generated has exists in this array. If we do indeed have a match, we can use setNotModified() to set a 304 status code on the response.

IfMatch Middleware

Handling If-Match will be slightly more complicated. The What it all comes down to is that we have to find a way to get the current version of the content that should be updated. This can be done in multiple ways.

  • You could use a HTTP client and make an external GET request for the same resource
  • You could look at the action that will be performed by the current request and instead call the GET request equivalent of that (for example calling the show() method on a controller)
  • Or you can make a new internal GET request.

When building this middleware I started off trying to use the second option. This for some reason seemed like the best option to me. I managed to create a fully working version, but could not be happy with the result. To make it work I had to make some assumptions, had some limitations and do too much work that would be done for me would I simply handle it by creating a new request.

So let's look at the code when we create a new request to fetch the current version of a resource:

    public function handle(Request $request, Closure $next)
    {
        // Next unless method is PATCH and If-Match header is set
        if (! ($request->isMethod('PATCH') && $request->hasHeader('If-Match'))) {
            return $next($request);
        }

        // Create new GET request to same endpoint,
        // copy headers and add header that allows you to ignore this request in middlewares
        $getRequest = Request::create($request->getRequestUri(), 'GET');
        $getRequest->headers = $request->headers;
        $getRequest->headers->set('X-From-Middleware', 'IfMatch');
        $getResponse = app()->handle($getRequest);

        // Get content from response object and get hashes from content and etag
        $getContent = $getResponse->getContent();
        $getEtag = '"'.md5($getContent).'"';
        $ifMatch = $request->header('If-Match');

        // Compare current and request hashes
        if ($getEtag !== $ifMatch) {
            return response(null, 412);
        }

        return $next($request);

All of this middleware will run at the start of the request lifecycle. First, we'll filter out any non-PATCH requests or ones that don't have the If-Match header set. After this, we'll make a new GET request to the same endpoint, and duplicate the headers from the initial request so the new one can pass through things like auth middleware and other constraints.

Using the response of this new request, we'll once again generate a hash that we can compare to the hash sent. If the hashes match, the request will be allowed through the middleware. If there is no match, a response with status code 412 will be returned.

With these 3 middlewares, you'll be able to handle etags and conditional requests easily within your Laravel application.

Package: https://github.com/365Werk/etagconditionals