Full Page Caching in PHP using Symfony

Static sites rock. Period. They are easy to deploy, they are easy to develop, they are easy to scale, and they are fast as hell. There is nothing faster than a statically served file speaking in terms of web performance.

But we also know that they are hard to maintain for non-tech people. Even if that is just one or maybe two cons against all the pros, it's a critical one. So it turns out the majority of website or apps on the web aren't statically served ones, they are dynamic ones. Those dynamic ones can be made manageable by the developers. But that of course comes with a price. (No talking about developer salary here)

Performance.

But what if we need our website to be both, easy to maintain and lightning fast as well? If we analyzed our site or app from the inside out, tweaked every cog possible, checked every external call and the performance is still not where we would like it to be.

Then there is probably only one thing left: Cache the fu** out of your website or webapp.

Let's have a look how that would look like by using the Symfony HTTP Cache working as a full-page caching reverse proxy.

Lets's code

It's actually pretty simple, we'll add a CacheKernel.php file next to our already existing Kernel.php file in the src folder.

namespace App;

use Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache;

class CacheKernel extends HttpCache { }

Yes, that's correct. That file essentially does nothing, because all the code to make it work has already been written by the Symfony team. So all we need to do is extend the HttpCache class and leave them a hug.

Also, the index.php file in our public folder needs some minor enhancements.

use App\CacheKernel;
use App\Kernel;

// ...

$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);

if ('prod' === $kernel->getEnvironment()) {
    $kernel = new CacheKernel($kernel);
}

// ...

We omitted the rest of the file and just added the changes that need to be done. First of course add the use statement for the newly created file.

Second, after the kernel is being created, we are checking for the current environment, prod in this case. So whenever we are starting Symfony up using the prod environment, we are wrapping the usual kernel inside our caching kernel.

After having that ready, we are basically done. Symfony is now behaving like a reverse proxy, eager to cache full page responses. But... There is a big but, we also need to tell Symfony what it should cache, it is not automatically going to cache just everything, but only those responses that would also tell a regular reverse proxy to cache the response.

So in our controller classes, whenever we want Symfony to cache the whole response, we ought to tell it so by setting the Cache-Control Header using the s-maxage property. Of course Symfony provides us with a convenient method to do so.

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class WebController extends AbstractController
{
    // ...

    public function home()
    {
        $content = // fetch data from somewhere ... CMS, Database, API, the air ...
        $response = $this->render('home.html.twig', [
            'body' => $content['body']
        ]);

        $response->setSharedMaxAge(3600);

        return $response;
    }

    // ...
}

Using the setSharedMaxAge() method we are able to set the caching time in milliseconds. And that's essentially it, call that URL and verify that the Cache-Control Header is present, having the s-maxage property set to 3600.

Checking the numbers

What again are we doing this for? Ah yeah, speed. So let's check to see if we actually improved the speed of our website, and if those 3 little code edits were really worth it.

First off, Symfony production environment with CMS data cached, but having the full page cache disabled.

Well, that's not too shabby. All the data is already cached using symfonys caching abstraction, but it still has to be processed by the framework and delivered to the user. Which is taking 37ms on our local machine.

So what's what, let's see the results for when the full page cache is enabled.

Oh boy, we shedded away 30ms of processing time, leaving us with 7ms for the transport of the cached response. Of course this is the result from our local machine, so that is as fast as it gets, but also saving some 20-30ms on the server for processing cached data is a drastic improvement.

Congratulations!

Nice! Now you should be able to use the Symfony HTTP Cache as a reverse proxy for your application, caching key responses from your website or application as a whole, improving the performance of the whole site.