Skip navigation
Tyssen Design — Brisbane Freelance Web Developer
(07) 3300 3303

File name versioning of static assets using Laravel Mix

By John Faulds /

If you're a conscientious web developer you make sure when you update static assets, like CSS and javascript, that your site forces a visitor to download a fresh copy with the updated code in it. This is otherwise known as cache-busting.

There are a few different ways to do this. There is file name, e.g. file.xxxx.css, or file path versioning /xxxx/file.css, and query string versioning, e.g. file.css?v=xxxx.

Query strings are known to have caching issues with some CDNs. This was discussed as far back as 2008 by renowned web performance expert, Steve Souders, but the advice still holds true today.

I've been using Laravel Mix for a while now as part of my front-end build process for compiling static assets, and it has a version function that can be used to create unique file names that are stored in a mix-manifest.json file.

You can then reference those unique file names in your code if you're running a Laravel project with, for example, mix('css/filename.css'). (We'll look at how you can do this on Craft CMS sites later.)

Mix's versioning drawback

The problem with using Mix's version function is that since version 1, it outputs file names with query strings, not by altering the path or file name. There was some discussion last year about it being an option to be added to version 6.1, but it's not there yet.

So what do you do if you want file- or path-based versioning now? I took a look at this a while ago and didn't find a solution so moved on, but this week I was looking at it again, and found the answer on StackOverflow (as is often the case).

I had to pull a few different bits and pieces together to get this to work, so I thought it'd be worth putting it all in one place to share.

The process

First, we're going to use Mix's version function which will populate a mix-manifest.json file with something that looks like this:


{
  "/assets/css/app.min.css": "/assets/css/app.min.css?v=e55c8dcc5e436f95623f",
  "/assets/js/app.min.js": "/assets/js/app.min.js?v=701522b5764ec3a93391"
}
				
mix-manifest.json before

I'm assuming you already have Laravel Mix installed and for my example, I'm also using the PurgeCSS wrapper for Laravel Mix.

The webpack.mix.js file I'm using that creates the mix-manifest.json file looks like this:


let mix = require('laravel-mix');
require('laravel-mix-purgecss');

mix

// These files aren't compiled by Webpack, but I want them to be versioned anyway

.version([
  'public-folder/assets/js/file1.js',
  'public-folder/assets/js/file2.js'
])

// Combine js files

.combine([
  'src/js/file1.js',
  'public-folder/assets/js/vendor/file1.js',
  'public-folder/assets/js/vendor/file2.js'],
'public-folder/assets/js/app.min.js')

// Run PostCSS on CSS files

.postCss('src/css/app.css', 'public-folder/assets/css/app.min.css')
    .options({
      postCss: [
        require('postcss-import'),
        require('postcss-nested'),
        require('postcss-custom-properties'),
        require('autoprefixer'),
      ]
    })
    .purgeCss({
      extend: {
        content: [
          './templates/*.twig',
          './templates/**/*.twig',
          './public-folder/assets/js/*.js'
        ],
        extensions: ['js', 'twig']
      },
    })
    .setPublicPath('public-folder')
    .version()
    .sourceMaps()
    .disableNotifications()
;
				
webpack.mix.js

One thing to note about the above is that when you add the version function to your script, you also need to add setPublicPath('public-folder'), otherwise you'll get an error like:

UnhandledPromiseRejectionWarning: Error: ENOENT: no such file or directory, open '/public-folder/assets/js/file1.js'

With this webpack.mix.js file, when we run npm run production, you'll get output with query strings on the end of the file names.

But we want our file names to look something like this:


{
  "/assets/css/app.min.css": "/assets/css/app.e55c8dcc5e436f95623f.min.css",
  "/assets/js/app.min.js": "/assets/js/app.701522b5764ec3a93391.min.js"
}
				
mix-manifest.json after

So we need to run a script that will convert those file names which comes from the StackOverflow question linked above which I modified a bit:


// Parse the contents of mix-manifest.json and create an array of file names
let fs = require('fs'),
    mixManifestPath = `${process.cwd()}/public-folder/mix-manifest.json`;
    mixFileContents = fs.readFileSync(mixManifestPath)
    oldMapping = JSON.parse(mixFileContents);

// This is the function that will rewrite the file names
function updateMapping(path) {
    
    // If the path doesn't have a ? for a query string, move on
    if(path.indexOf("?") < 1) {
        return path;
    }

    // Split the file name at the ?
    let [filename, idPart] = path.split("?"),
        // Then again at the = to just get the hash
        [_, id] = idPart.split("="),
        // Conditional output based on whether the file is css or js
        updated = (filename.includes('css') ? filename.replace('.min.css', '.'+id+'.min.css') : filename.replace('.min.js', '.'+id+'.min.js'))
    ;
    return `${updated}`;
}

// Create an empty array to which we're going to write the updated file names
let newContent = {};

// Loop through the array of file names we created above and pass the old file name to the updateMapping function for it to be updated
for(let file in oldMapping) {
    newContent[file] = updateMapping(oldMapping[file]);
}

// Write to the terminal that the task has completed 
console.log('Manifest rewritten');

// And write the updated contents back to mix-manifest.json
fs.writeFileSync(mixManifestPath, JSON.stringify(newContent, null, 2));
				
rewriteMixManifest.js

To be able to run that script, we need to add a new script to the package.json file like so:


"scripts": {
  "production:first": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --config=node_modules/laravel-mix/setup/webpack.config.js",
  "rewrite-manifest": "node assets/js/rewriteMixManifest.js",
  "production": "run-s production:first rewrite-manifest"
}
				
Add new scripts to package.json

I renamed the original production script to production:first (although it could be anything), added a new rewrite-manifest script and then use npm run production to run them both sequentially.

This is done with run-s which is a shorthand command provided by npm-run-all which you'll have to install first with npm i npm-run-all.

So we've now got the file names in mix-manifest.json the way we want them. I don't generally work on Laravel projects, so if I was using Craft which I'm more likely to be using, I'd install the AssetRev plugin and add an assetrev.php flle to my config folder.


<?php
return [
    // Update the paths to suit your setup
    'manifestPath' => 'public-folder/mix-manifest.json',
    'assetsBasePath' => '../public-folder/',
    'assetUrlPrefix' => '@web',
];
				
assetrev.php in the config folder

Then you call < link href="{{ rev('/_css/combined.min.css') }}" rel="stylesheet"> or < script src="{{ rev('/_js/app.min.js') }}"> in your templates.

The only thing left to do is to tell the server that when it encounters a file like app.x7ddjdj7x.min.css, which doesn't exist, that it treats it like the x7ddjdj7x part of the file name wasn't there and instead serves up app.min.css which does exist.

How you do that will depend on your web server, but in my case, on Apache, I added the following to the .htaccess file:



    RewriteEngine On
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteRule ^assets/(js|css)/(.+)\.\w+\.(js|css)$ assets/$1/$2.$3 [L,NC]

				
mod_rewrite rules

And that should be it. It's a few more steps than what I would generally like to have to make to achieve this result, but sometimes you just have to go that extra mile to achieve the best result.