Photo by Nick Hillier on Unsplash

Asset cache busting in Symfony applications

Different techniques to send the newest css and javascript files to your users.

Glynn Forrest
Friday, January 31, 2020

The problem

Suppose you have a Symfony application that is updated regularly. Every time you deploy a new version, you want to ensure the new versions of css and javascript files are sent to the user.

If the application serves a page like this:

<!DOCTYPE html>
<html>
  <head>
    <title>My brilliant app</title>
    <link rel="stylesheet" type="text/css" href="/app.css" />
  </head>
  <body>
    <div class="app">
      ...
    </div>
    <script src="/app.js"></script>
  </body>
</html>

We always want the user to see the new versions of app.css and app.js when they are changed. However, due to browser caching the user may see an older version until they do a ‘hard refresh’ of the page.

Here are some ways to get around this problem, and how to best leverage Symfony’s Asset component to help you.

Understand what the server is doing

Before we change anything in the app, it’s important to know how cache headers work.

At a simple level, there are four headers to consider: Expires and Last-Modified from the HTTP 1.0 spec, and Cache-Control and ETag from HTTP 1.1.

Expires and Cache-Control are used for expiration caching, for example:

$ curl -I https://example.com/app.css

HTTP/1.1 200 OK
Cache-Control: max-age=86400
Content-Length: 87346
Content-Type: text/css
Date: Fri, 31 Jan 2020 10:57:10 GMT
Expires: Sat, 1 Feb 2020 10:57:10 GMT

Expiration caching tells the browser to re-use the response until an amount of time has passed: 86400 seconds in the Cache-Control header, and the exact date and time in the Expires header. When the browser needs to request app.css again, it’ll load the file from its disk cache without making any HTTP request.

Last-Modified and ETag are used for validation caching, for example:

$ curl -I https://example.com/app.css

HTTP/1.1 200 OK
Content-Length: 87346
Content-Type: text/css
Date: Fri, 31 Jan 2020 10:57:10 GMT
ETag: "2de29-59d32eb4c46a1"
Last-Modified: Tue, 28 Jan 2020 13:08:25 GMT

Validation caching tells the browser to include a header when requesting the resource. This header can be used by the server to determine if it needs to send a fresh resource, or tell the browser to reuse its cached copy.

With Last-Modified, the browser will include the If-Modified-Since header with the request. If If-Modified-Since is before Last-Modified, the server will return a 200 OK response with the fresh resource, otherwise it will send a 304 Not Modified response with an empty body.

With ETag (a hash of the resource), the browser will include the If-None-Match header with the same hash. If the hashes don’t match (the resource has changed), the server will return a 200 OK response with the fresh resource, otherwise it will send a 304 Not Modified response with an empty body.

Note that with validation caching a request is still sent, so try to use expiration caching for css and javascript wherever possible.

You can’t always rely on a well-configured server to send cache headers correctly, especially if you use an external CDN or have multiple frontend caches, load balancers, etc, between you and your users. There are two common strategies to prevent these cache headers getting in the way: query strings and hashes in the filename.

Query strings

With a query string (e.g. /app.css?v=1), most browsers will treat the request as a new resource and download a fresh copy. When your files are updated, simply change ?v=1 to ?v=2.

Symfony asset helpers can take care of the fiddly parts for you. Update your HTML templates to use the asset() twig function, and set the asset version in the framework configuration:

<!-- base.html.twig -->
<!DOCTYPE html>
<html>
  <head>
    <title>My brilliant app</title>
    <link rel="stylesheet" type="text/css" href="{{asset('app.css')}}" />
  </head>
  <body>
    <div class="app">
      ...
    </div>
    <script src="{{asset('app.js')}}"></script>
  </body>
</html>
# config/framework.yaml

framework:
    assets:
        version: 1
        version_format: '%%s?v=%%s'

The rendered HTML will now include the query string in the URL:

<!DOCTYPE html>
<html>
  <head>
    <title>My brilliant app</title>
    <link rel="stylesheet" type="text/css" href="/app.css?v=1" />
  </head>
  <body>
    <div class="app">
      ...
    </div>
    <script src="/app.js?v=1"></script>
  </body>
</html>

To update the version for all asset URLs, simply change the version number:

  framework:
      assets:
-         version: 1
+         version: 2
          version_format: '%%s?v=%%s'

You could also do something a bit fancier: create a kernel parameter that changes automatically every time you clear the cache, or use an environment variable.

Be aware that query strings aren’t always reliable, so use them with caution.

Hashed filenames

A more reliable approach than a query string is to include asset versioning in the filename itself. Instead of app.css, your file would be called app.a1b2c3d4.css or even a1b2c3d4.css. Each time the file changes, a new hash and filename is calculated.

Tools like webpack can handle this automatically for you:

module.exports = {
  entry: {
    app: ['path/to/app.css', 'path/to/app.js'],
  },
  output: {
    filename: '[name].[hash].js'
  },
  ...
}

Every time webpack builds app.css and app.js, the filename will include a hash of the output.

We need a way to get Symfony’s asset helper to go from app.css to app.[hash].css. Fortunately the webpack-manifest-plugin does exactly that, generating a json file mapping between the two:

{
  "app.js": "app.c34e32de96f494ff5038.js",
  "app.css": "app.c34e32de96f494ff5038.css"
}

You can then use the json_manifest_path option in the framework configuration to hook it up to Symfony:

# config/framework.yaml

framework:
    assets:
        json_manifest_path: '%kernel.project_dir%/public/manifest.json'

The asset() twig function can be used the same as before:

<!-- base.html.twig -->
<!DOCTYPE html>
<html>
  <head>
    <title>My brilliant app</title>
    <link rel="stylesheet" type="text/css" href="{{asset('app.css')}}" />
    ...
<!DOCTYPE html>
<html>
  <head>
    <title>My brilliant app</title>
    <link rel="stylesheet" type="text/css" href="/app.c34e32de96f494ff5038.css" />
    ...

Watch out for missing manifest errors

The json_manifest_path asset option will throw a nasty error if the file doesn’t exist:

This is helpful when deploying your app to production (you want to know when asset files are missing), but can be frustrating when running tests, e.g. on a continuous integration server. To get any functional tests to run correctly, you’ll need to build your assets to get a manifest file. Depending on the amount of assets you have, this can add considerable time to your CI build!

The issue has been raised with the Symfony team and the behaviour confirmed as intentional; see this github issue.

Fortunately there’s a simple workaround; create an empty manifest json file:

echo '{}' > path/to/manifest.json

The asset files will still be missing, but you’ll be able to render twig templates in your tests without having to compile all your assets in advance.

Further reading

  • Things Caches Do by Ryan Tomayko explains how cache headers affect the response you receive from a web server;
  • HTTP Caching guide by Paul James, another useful resource on cache headers;
  • Symfony’s Webpack Encore javascript package offers a simple way to configure webpack to generate files with hashes included;
  • The Symfony Asset component documentation explains what goes on behind the scenes with the asset helpers.

More from the blog

Why Symfony's container is fast cover image

Why Symfony's container is fast

Glynn Forrest
Monday, September 30, 2019

Subscribe to our mailing list

Receive periodic updates about our products, services, and articles.

View recent emails