Extreme caching with PSR-7

by Mateusz Tymek — on PHP

PSR-7 brought some interesting patterns that can be applied to PHP application regardless of what framework it uses. It is particularly interesting when it comes to performance - no matter what technology your project uses, you can apply the same techniques to make it faster.

Here I will show how PSR-7 middleware can be used to cache application's output. I call it "extreme caching", because I want to trigger it as early as possible, in order to reduce amount of code to be executed on each request.

I will present this pattern on Zend Expressive-based application. It will work for any PSR-7 framework that uses middleware with following signature (which has become de facto standard):

function ($request, $response, $next) { }

File-based caching

Before starting, let's decide when caching should be enabled - you don't want to cache any page that is dynamic, but everything static (blog post, "about us" page, ...) is fine. This decision should be based on what comes in from the user (Request) and on what comes out from your application (Response).

In case of this blog, I cache all GET requests that return HTTP 200 status. Here's a middleware class that saves output into file if this conditions are met:

class CachingMiddleware
{
    public function __invoke(
        ServerRequestInterface $request,
        ResponseInterface $response,
        callable $next
    ) {
        // check if request can be cached
        if ($request->getMethod() != 'GET') {
            return $next($request, $response);
        }

        // return early if page is cached
        if ($html = $this->getCachedHtml($request)) {
            return new HtmlResponse($html);
        }

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

        // check if response can be cached
        if ($response->getStatusCode() == 200) {
            $this->cacheResponse($request, $response);
        }

        return $response;
    }
}

Note: example above is missing two methods (getCachedHtml and cacheResponse). Full version can be found in this gist.

Enabling this middleware in Expressive is super easy. Can be done programmatically:

$app = AppFactory::create();
$app->pipe(new CachingMiddleware();

Or, it can be injected using configuration file (when using ApplicationFactory):

return [
    'middleware_pipeline' => [
        'pre_routing' => [
            ['middleware' => CachingMiddleware::class],
            ['middleware' => FooBarMiddleware::class],
            ['middleware' => AnotherMiddleware::class],
        ],
        'post_routing' => [
            ['middleware' => ErrorHandlerMiddleware::class, 'error' => true],
        ],
    ],
];

Because of what it does, CachingMiddleware should be added to the stack as early as possible - ideally as a first element.

Early caching

While bringing good speed increase, it is not everything that we can get out of Expressive. With middleware added to Application class, Expressive will still execute a lot of code to setup everything. Fortunately, Application is a middleware itself, so it can be wrapped around "inside" our caching system.

This requires to change how application is ran. You cannot use run() method anymore, you have to invoke Application directly and manually send response via emitter. It's still pretty simple - just wrap code that creates application in a closure, and pass it to CachingMiddleware:

// index.php
use Zend\Diactoros\ServerRequestFactory;
use Zend\Diactoros\Response;
use Zend\Expressive\AppFactory;

include 'vendor/autoload.php';

$cache = new CachingMiddleware();
$response = $cache(
    ServerRequestFactory::fromGlobals(),
    new Response(),
    function ($req, $res, $next = null) {
        // create Expressive Application
        $app = AppFactory::create();
        $app->get('/', function ($req, $res) {
            $res->getBody()->write('Hello, World!');
        });
        return $app($req, $res, $next);
    }
);
$emitter = new Response\SapiEmitter();
$emitter->emit($response);

Apply this and you should see incredible increase of page loading time.

HTTP caching

File-based caching works really well when your infrastructure is simple and you don't have additional caching layer available. If your app is behind Varnish, CloudFlare, or any similar service, caching is much easier - usually it is enough to feed them with correct response headers and they will do all the caching for you. Exact headers to be set may vary from one solution to another. Here's what will work with Amazon's CloudFront:

class HttpCacheMiddleware
{
    public function __invoke(
        ServerRequestInterface $request,
        ResponseInterface $response,
        callable $next
    ) {
        $response = $next($request, $response);

        if ($request->getMethod() != 'GET' || $response->getStatusCode() != 200) {
            return $response;
        }

        $maxLifetime = 3600; // cache for 1 hour
        $response = $response->withHeader(
            'Expires',
            gmdate("D, d M Y H:i:s", time() + $maxLifetime) . " GMT"
        );

        return $response;
    }
}

If you have a choice, always use HTTP headers instead of files. Dedicated caching layer will be better optimized and more flexible. Plus, this middleware doesn't need to be invoked early, so it is easier to use it.

Existing caching middleware

oscarotero/psr7-middlewares comes with SaveResponse middleware, very similar to what I described here.


comments powered by Disqus