This site looks a lot better with CSS turned on. Note: IE<10 is not supported.

an article is displayed.

Symfony 4: using ParcelJs with Twig templates

Using Parcel in a Symfony 4 project

Introduction

I've used Webpack for a couple of projects so far, and it's undoubtedly been a useful tool. If you have a need for a Javascript based static module bundler then you'll more than likely appreciate its popularity and granular configuration.

Webpack's flexibility and power comes at the price of a fairly steep learning curve, however, and whilst the time required to learn its intricacies turned out to be a sound investment for my more complicated bundling requirements, it all started to feel a bit unnecessary when I just wanted to spin up something straight forward.

ParcelJs brings something slightly different to the table, and whilst it won't replace Webpack for complicated bundling, it more than makes up for it with speed and increased simplicity.

As I've been getting into PHP quite recently, I decided to find out whether Parcel plays nicely with the Symfony 4 framework and its default Twig templating system.

There may well be other, better ways to achieve the same thing with Parcel and Twig, but the following article describes a process that has fulfilled my needs so far.

It's worthwhile to note that Symfony 4 provides Encore, which is a simpler way to integrate Webpack into your application. If you're still trying to decide which bundler to use for your application, then you might want want to look into that too.

I notice there have been efforts to create a plugin that will allow Parcel to deal with Twig templates internally. I couldn't find anything that was in a state of completion at the time of writing this article, but be sure to do your own research in case something has been released in the meantime.

This article has been updated (29th April 2020): it was recently pointed out (see Sebastian Correa's comments in the section at the end of the article) that my ParcelJs / Twig solution was incompatible with Parcel's inbuilt cache-busting / file naming strategy, and so I've updated this article to provide a possible workaround, along with a few other ideas I came up with along the way to help with code splitting. Since the workaround involves a bit of scripting, it's debatable whether some of Parcel's simplicity has been lost in the process, but I found it to be an interesting exercise nevertheless. I'll leave it up to you to to decide whether or not to proceed down the same route. I've left my original article untouched for the most part, and then I've detailed the changes that I needed to make in a later section.

I've also created a Github repo for anyone that wants to dive straight into the code.

Goals

By the end of this article, you should be able to:

  • set up a skeleton Symfony 4 project
  • create a basic pipeline with ParcelJs that will bundle Javascript and CSS assets
  • display the bundled assets using Symfony's Twig templates
  • display certain assets inline
  • employ cache-busting and code-splitting techniques

Prerequisites

  • OSX Mojave
  • A local installation of the Composer dependency manager
  • Basic knowledge of PHP and the Symfony framework
  • A local installation of the latest stable NodeJs release (12.9.1 at the time of writing)
  • Basic knowledge of asset bundling

OSX Mojave isn't an essential requirement, but that's what I've used to test everything.

You can probably get away with an earlier version of NodeJS, but again this is what I used to test the process.

Create a Symfony 4 skeleton project

If you're unsure about this process then please read my article that explains how to create a new Symfony project from scratch.

For our purposes here, I'm using hackerbox_symfony_4_twig_parcel for the new project name, so if you wish to follow along exactly then be sure to use this instead of the name that I use in the other article.

Create a new Controller

We'll need a controller to test out the results of our asset bundling.

Open up the new project in your favourite code editor

Create the file src/Controller/HomeController.php, and paste in the following code:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;


class HomeController extends AbstractController
{
    /**
     * @Route("/", name="home")
     */
    public function index()
    {
        return $this->render('home/index.html.twig');
    }
}

The controller we've made is about as basic as it gets really. I've used the route annotations so that we don't have to be bothered with a separate routing file, but that's about it.

Create a Twig template

At the moment, our controller method will attempt to render a template that doesn't exist, so let's create a simple twig template to fix that.

Create the file templates/home/index.html.twig, and paste in the following code:

{% extends 'base.html.twig' %}
{% block body %}
    <div class="example-wrapper">
        <h1>Symfony 4: using ParcelJs with Twig templates</h1>
        <p>Hello from Hackerbox.io</p>
    </div>
{% endblock %}

Refresh your browser, and you should see the contents of the template that we created. Black text, white background, not exactly thrilling.

The results of a very basic twig template

Before we move on though, let's try to understand a bit more about what's going on here.

If you view the source of our page in your web browser, you should see something similar to the following:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>Welcome!</title>
            </head>
    <body>
            <div class="example-wrapper">
        <h1>Symfony 4: using ParcelJs with Twig templates</h1>
        <p>Hello from Hackerbox.io</p>
    </div>
</body>
</html>

You'll probably find that your browser's page source shows a lot of stuff between lines 10 and 11 that I didn't include in the example above. That's just the code that Symfony injects to create the handy "sticky bar" that appears at the bottom of the screen during development. It isn't relevant for our purposes, so just ignore it for now.

Lines 8 - 10 contain the markup that we put in our template, but everything else comes from the base template.

If you take another look at our template code, you'll see that we actually extended base.html.twig, and added a block named body.

{% extends 'base.html.twig' %}
{% block body %}
    <div class="example-wrapper">
        <h1>Symfony 4: using ParcelJs with Twig templates</h1>
        <p>Hello from Hackerbox.io</p>
    </div>
{% endblock %}

This tells the Twig templating system to first load the base.html.twig template, and then inject the markup that it finds between block and endblock tags (in index.html.twig in this case) into the corresponding references in base.html.twig.

Here we've named our block body, and if we open up the templates/base.html.twig file, we can see that it contains, amongst other things, a body block too (line 9):

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        {% block stylesheets %}{% endblock %}
    </head>
    <body>
        {% block body %}{% endblock %}
        {% block javascripts %}{% endblock %}
    </body>
</html>

We'll need to deal with some of those other blocks shortly, so let's get on with creating our assets.

Adding styles and scripts

In the project root, create the file styles/main.css, and paste in the following code:

body {
    background-color: #ff0077;
    color: #00ff00;
}

h1 {
    font-weight: bold;
    color: #fff;
}

In the project root, create the file styles/homePage/index.css, and paste in the following code:

@import '../main.css';

In the project root, create the file scripts/main.js, and paste in the following code:

export default class Messages {
    static SayHello () {
        return 'Hello from Messages!';
    }
}

In the project root, create the file scripts/homePage/index.js, and paste in the following code:

import Messages from '../main';

console.log(Messages.SayHello());

Installing ParcelJs

Our assets aren't much good to us in their current location, but as we'll be using Parcel for our pipeline, it's time to get that installed.

Stop your development server (or open a new terminal window), navigate to the project root, and run the following command to create a package.json file:

npm init

For our purposes here, it's fine to provide the default answers to the questions when prompted.

Run the following command to install Parcel globally:

npm install parcel-bundler -g

Type the following command to ensure that Parcel has installed properly.

parcel --help

You should see a list of options. For example:

Usage: parcel [options] [command]
.......

Creating the asset bundles

At this point, we can see Parcel in action.

Navigate to the project root, and run the following command to bundle our scripts:

parcel build scripts/homePage/index.js --out-dir public/assets --out-file homePage.bundle.js --no-source-maps

This will actually create a production bundle of our scripts, which will "minify" the code and avoid creating source maps, but we'll come to how we can create something more suitable for development later on.

The above command tells Parcel to take the code in the file scripts/homePage/index.js, and bundle it along with the code contained in any of its associated import statements into a single file in Symfony's default public directory (public/assets/homePage.bundle.js). The code in the resulting file will be minified, as mentioned previously, and no source maps will be created thanks to the --no-source-maps flag.

Check that a file containing minified javascript has been created at public/assets/homePage.bundle.js.

We can now do the same for our css files:

Navigate to the project root, and run the following command to bundle our styles:

parcel build styles/homePage/index.css --out-dir public/assets --out-file homePage.bundle.css --no-source-maps

Check that a file containing minified CSS has been created at public/assets/homePage.bundle.css.

Using the bundles

In order to make use of the scripts and styles that we bundled, we'll need to reference them in our twig template using the stylesheets and javascripts blocks that I mentioned earlier.

Open the home/index.html.twig template, and add the stylesheets and javascripts block as follows:

{% extends 'base.html.twig' %}

{% block stylesheets %}
    <link rel="stylesheet" href="/assets/homePage.bundle.css" />
{% endblock %}

{% block body %}
    <div class="example-wrapper">
        <h1>Symfony 4: using ParcelJs with Twig templates</h1>
        <p>Hello from Hackerbox.io</p>
    </div>
{% endblock %}

{% block javascripts %}
    <script src="/assets/homePage.bundle.js"></script>
{% endblock %}

Make sure your project is running, and refresh your browser. You should see the garish screen shown below:

The effects of the bundled assets are rendered in a web browser

Isn't that beautiful. An inspiration for designers everywhere.

Well, maybe not, but it illustrates very clearly that our bundled styles have been successfully rendered.

If you check the Javascript console, you should see that the bundled scripts have run too, and that a message has been printed: "Hello from Messages!"

Bundling assets during development

So far, we've seen how to create minified bundles using scripts that were run directly on the command line. This is all well and good, but we can make the process a little bit neater and set things up so that the bundles will be created automatically when changes are made to the associated source files.

Make sure your project is running, open another terminal window in the project root, and run the following command:

parcel watch scripts/homePage/index.js --no-hmr --public-url /assets/ --out-dir public/assets --out-file homePage.bundle.js & parcel watch styles/homePage/index.css --no-hmr --public-url /assets/ --out-dir public/assets --out-file homePage.bundle.css

Open the styles/main.css, and make a change to the styles. Something obvious, like the background colour, for example.

Refresh your browser, and you should see that your change has taken effect.

Open the scripts/main.js, and make a change to the message that is output to the console.

Refresh your browser, open the javascript console, and you should see that your change has taken effect.

What we've actually done here is run two commands that will start two simultaneous processes: one that watches for changes in .js files, and another that watches for changes in .css files.

The watch flag tells Parcel to watch for changes in the source files, and then the relevant bundles are recreated whenever a change is detected.

This is really handy during the development cycle, and it's far easier having the process triggered automatically rather than having to mess around in the terminal every time you make a change.

It's possible to take the process further by using Parcel's Hot Module Replacement (HMR) feature, but that's beyond the scope of this article. I've turned HMR off for our purposes here, using the --no-hmr flag. Read the documentation to find out more about HMR :)

The only other thing to note is the --public-url flag. This accepts a value that specifies the location that the assets are being served from, and is used to provide a reference in the bundle to its associated source map file.

If you open up homePage.bundle.css, for example, you should see the following reference.

/*# sourceMappingURL=/assets/homePage.bundle.css.map */

When you're ready to do so, you can quit the watch processes with ctrl + c

Tidying up

It's all very messy running big long commands in the terminal, so it would be nice if we could tidy things up a bit.

Stop any watch processes that you might have running, open the package.json file, and alter the scripts section so that it looks like the example below:

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build_styles_prod": "parcel build styles/homePage/index.css --out-dir public/assets --out-file homePage.bundle.css --no-source-maps",
    "build_scripts_prod": "parcel build scripts/homePage/index.js --out-dir public/assets --out-file homePage.bundle.js --no-source-maps",
    "build_styles_dev": "parcel watch styles/homePage/index.css --no-hmr --public-url /assets/ --out-dir public/assets --out-file homePage.bundle.css",
    "build_scripts_dev": "parcel watch scripts/homePage/index.js --no-hmr --public-url /assets/ --out-dir public/assets --out-file homePage.bundle.js",
    "build_assets_prod": "find public/assets | grep -E '.*\\.bundle\\..*$' | xargs rm; npm run build_styles_prod & npm run build_scripts_prod",
    "build_assets_dev": "find public/assets | grep -E '.*\\.bundle\\..*$' | xargs rm; npm run build_styles_dev & npm run build_scripts_dev"
  },

The development build / watch process can now be started by running the following command in the terminal:

npm run build_assets_dev

The production build can now be started by running the following command in the terminal:

npm run build_assets_prod

You'll need to have your project running in one terminal window, and then run the NPM commands in a separate window if you're running build_assets_dev. We'll tidy that up shortly though.

You may have noticed that there's a bit of additional jiggery-pokery at the beginning of the build_assets_dev and build_assets_prod scripts:

find public/assets | grep -E '.*\\.bundle\\..*$' | xargs rm;

All this does is remove any pre-existing bundle files (the ones that have .bundle. in their name) so that the production build isn't affected by any artifacts that might remain from a previously run development build, or vice versa.

First, we find all the files in the public/assets directory:

find public/assets

We then pipe the resulting files names through to the grep command, which returns only the files that have .bundle. in their name:

| grep -E '.*\\.bundle\\..*$'

Finally, we pipe the filtered results into xargs, which passes the identified file names on to the rm command.

| xargs rm;

At this point, our development process still involves having two terminal windows open. This might be fine, or even desirable for some people, but nevertheless we can simplify our process further using composer.json.

Open the composer.json file, and add run-local-dev and run-local-prod to the scripts section, similar to the example below:

"scripts": {
    "auto-scripts": {
        "cache:clear": "symfony-cmd",
        "assets:install %PUBLIC_DIR%": "symfony-cmd"
    },
    "post-install-cmd": [
        "@auto-scripts"
    ],
    "post-update-cmd": [
        "@auto-scripts"
    ],
    "run-local-dev": "npm run build_assets_dev & php bin/console server:run",
    "run-local-prod": "npm run build_assets_prod & php bin/console server:run"
},

Now you should be able to start the Symfony project and start watching both .css and .js files from composer. Try stopping the development server, along with any watch processes, and run:

composer run-local-dev

If you want to do the same thing with a production build (without watchers), you can run the following command:

composer run-local-prod

In practice, the production build might never really be run locally, but it's here just to illustrate a concept.

Inlining styles

The main reason to bundle assets is to reduce the number of HTTP requests required to render a web page. If you have two files referenced in the initial HTML response from a web server, for example, then a browser would need to make two additional requests to retrieve this files. Assuming that the files are small enough, combining them into a single file would then only require one additional HTTP request to retrieve it.

This is a bit of an over simplification, so do your own research around bundling and optimisation.

A potential downside to requiring additional files, however, is that those files will be downloaded after the initial HTML has been rendered in the browser, which can often cause the page to flash or jiggle while the browser updates everything.

Nowadays, it's popular practice to embed, or "inline", certain assets within the initial response from the web server in order to give the user the perception of a "visually ready" page as soon as possible, despite the resulting increase in the size of the initial response.

Typically these inlined assets (styles, scripts, images, fonts, etc) are essential to the page's fundamental structure, and provide the user with everything required for a pleasant experience, whilst less important functionality and styling is loaded in later on by the bundles.

The example I'm using in this tutorial is far too simple to be realistic, but we can still use it to take a look at how we might go about inlining assets that have been processed by Parcel.

Let's create some "essential" assets that we can inline.

Create the file styles/essential.css, and paste in the following code:

body {
    font-family: "Comic Sans MS", cursive, sans-serif;
}

Create the file scripts/essential.js, and paste in the following code:

(function inlineJsExample() {
    console.log('HELLO, FROM AN INLINE JAVASCRIPT!');
})();

We'll also need a bit of additional code to help us out, so create the file src/Controller/Helpers/ClientBundleHelpers.php, and paste in the following code:

<?php

namespace App\Controller\Helpers;


class ClientBundleHelpers {
    /**
     * @return bool|string
     */
    public static function getEssentialCssInline() {
        return file_get_contents(__DIR__ . '/../../../public/assets/essential.inline.css');
    }

    /**
     * @return array|false|string
     */
    public static function getEssentialJsInline() {
        return file_get_contents(__DIR__ . '/../../../public/assets/essential.inline.js');
    }
}

I've used a couple of hard-coded paths to asset files there. You might want to replace the paths with environment variables, and maybe pass the names of the assets in as parameters to the functions, but I'll leave that choice to you.

The above code is pretty simple. All we're really doing is using PHP's file_get_contents function to return the contents of the specified files rather than the entire files themselves.

We also need to tweak our controller method a little bit if we're going to make use of those helpers. Open up the controller (scr/Controller/HomeController.php), and update the code so that it looks like the following:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use App\Controller\Helpers\ClientBundleHelpers;


class HomeController extends AbstractController
{
    /**
     * @Route("/", name="home")
     */
    public function index()
    {
        return $this->render(
            'home/index.html.twig',
            [
                'essential_styles' => ClientBundleHelpers::getEssentialCssInline(),
                'essential_scripts' => ClientBundleHelpers::getEssentialJsInline()
            ]
        );
    }
}

Here, we're taking the asset file contents that were returned from our helper methods, and making them available to our Twig templates in the essential_styles and essential_scripts variables.

Those variables aren't yet referenced in our Twig template, so let's fix that.

Open up the templates/base.html.twig file, and alter the contents so that it appears as follows:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        {% block stylesheets %}{% endblock %}
        {% if essential_styles is defined and essential_styles is not empty %}
            <style>
                {{ essential_styles|raw }}
            </style>
        {% endif %}
    </head>
    <body>
        {% block body %}{% endblock %}
        {% block javascripts %}{% endblock %}
        {% if essential_scripts is defined and essential_scripts is not empty %}
            <script>
                {{ essential_scripts|raw }}
            </script>
        {% endif %}
    </body>
</html>

There are a couple of things to pay attention to here:

{% if essential_styles is defined and essential_styles is not empty %}
    <style>
        {{ essential_styles|raw }}
    </style>
{% endif %}
{% if essential_scripts is defined and essential_scripts is not empty %}
    <script>
        {{ essential_scripts|raw }}
    </script>
{% endif %}

In both of those code snippets, we're referencing a variable and using the raw filter to ensure that the variable's content isn't HTML encoded before it's rendered. By default, Symfony provides automatic escaping, but we want the content of our asset files to appear in the source exactly as it appears in the files themselves. As you can see, the asset contents are injected between style and script tags respectively.

Automatic escaping can help to prevent code injection vulnerabilities, so be careful where you're using the raw filter.

The checks on lines 7 and 16 are just defensive programming. We make sure that the essential_styles and essential_scripts variables exist before we try to use them, and also that they contain content. There's really no point in rendering an empty pair of tags.

We'll also need to update our NPM scripts, so open the package.json file update the scripts section with the following code:

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build_styles_prod": "parcel build styles/homePage/index.css --out-dir public/assets --out-file homePage.bundle.css --no-source-maps",
    "build_scripts_prod": "parcel build scripts/homePage/index.js --out-dir public/assets --out-file homePage.bundle.js --no-source-maps",
    "build_essential_styles_prod": "parcel build styles/essential.css --out-dir public/assets --out-file essential.inline.css --no-source-maps",
    "build_essential_scripts_prod": "parcel build scripts/essential.js --out-dir public/assets --out-file essential.inline.js --no-source-maps",
    "build_styles_dev": "parcel watch styles/homePage/index.css --no-hmr --public-url /assets/ --out-dir public/assets --out-file homePage.bundle.css",
    "build_essential_styles_dev": "parcel watch styles/essential.css --no-hmr --public-url /assets/ --out-dir public/assets --out-file essential.inline.css --no-source-maps",
    "build_essential_scripts_dev": "parcel watch scripts/essential.js --no-hmr --public-url /assets/ --out-dir public/assets --out-file essential.inline.js --no-source-maps",
    "build_scripts_dev": "parcel watch scripts/homePage/index.js --no-hmr --public-url /assets/ --out-dir public/assets --out-file homePage.bundle.js",
    "build_assets_prod": "find public/assets | grep -E '.*\\.(bundle|inline)\\..*$' | xargs rm; npm run build_essential_styles_prod & npm run build_essential_scripts_prod & npm run build_styles_prod & npm run build_scripts_prod",
    "build_assets_dev": "find public/assets | grep -E '.*\\.(bundle|inline)\\..*$' | xargs rm; npm run build_essential_styles_dev & npm run build_essential_scripts_dev & npm run build_styles_dev & npm run build_scripts_dev"
},

Restart your project, and then run one of the scripts we created earlier (composer run-local-dev or composer run-local-prod). Refresh your browser.

You should see that the new Comic Sans MS font has been applied:

Inline assets are render in a web browser

If you check the Javascript console, you should also see that the "HELLO, FROM AN INLINE JAVASCRIPT!" message has been output.

The page source should show that our essential scripts and styles have been added directly into the HTML response. If you started the project with composer run-local-dev, then the inline assets will not be minified, and will be automatically updated when you make changes to the source, whereas the composer run-local-prod should have minified the inline assets.

Styles (unminified):

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>Welcome!</title>
            <link rel="stylesheet" href="/assets/homePage.bundle.css" />
                    <style>
                body {
    font-family: "Comic Sans MS", cursive, sans-serif;
}
            </style>
            </head>

Scripts (unminified):

})({"essential.js":[function(require,module,exports) {
(function inlineJsExample() {
  console.log('HELLO, FROM AN INLINE JAVASCRIPT!');
})();
},{}]},{},["essential.js"], null)
            </script>

Adding Cache Busting and Code Splitting

As I mentioned in the introduction, I've updated this article to provide a solution for cache-busting, and also to take advantage of Parcel's inbuilt support for code-splitting. It's debatable whether or not this workaround is sufficiently complex to warrant a move away from Parcel (until a Twig plugin is available at least), but I'll describe the steps I took to deal with the problem in any case.

Changes to the directory structure

The first obstacle I faced was Parcel's refusal to accept more than one out-file. Initially, I was adding custom suffixes to my various asset files (*.inline.css, *.inline.js, *.bundle.css and *.bundle.js), which was achieved by simply passing the asset name to Parcel's --out-file argument.

I wanted to use the built-in cache busting and code splitting functionality, so I needed to allow Parcel to create the names for the asset files itself.

New development scripts

Open up the package.json file, and alter the contents of the scripts section so that it appears as follows. We're just dealing with development scripts at the minute, but we'll get onto production shortly.

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build_styles_dev": "parcel watch styles/homePage/*.css --no-hmr --out-dir public/assets --public-url /assets/",
    "build_scripts_dev": "parcel watch scripts/homePage/*.js --no-hmr --out-dir public/assets --public-url /assets/",
    "build_essential_styles_dev": "parcel watch styles/essential.css --no-hmr --out-dir public/assets --no-source-maps",
    "build_essential_scripts_dev": "parcel watch scripts/essential.js --no-hmr --out-dir public/assets --no-source-maps",
    "build_assets_dev": "find public/assets/* | xargs rm -r; npm run build_essential_styles_dev & npm run build_essential_scripts_dev & npm run build_styles_dev & npm run build_scripts_dev"
},

There's quite a lot going on in the above example, but notice just for now that I've removed the --out-file arguments from the parcel build and parcel watch commands, and that I'm instead relying on Parcel to create the names and paths for the files inside the public/assets directory specified on the --out-dir argument. I've also relaxed some of the glob patterns as they felt a bit unnecessary.

New bundling methods

We need to make a change to src/Controller/Helpers/ClientBundleHelpers.php too, since we're no longer using the same naming convention, so open it up and amend the ClientBundleHelpers class so that it looks like:

class ClientBundleHelpers {

    private const BASE_ASSET_PATH = '/../../../public';
    private const ASSET_DIR_NAME = 'assets';

    /**
     * @return array|false|string
     */
    public static function getEssentialCssInline() {
        return file_get_contents(__DIR__ . self::BASE_ASSET_PATH . '/' . self::ASSET_DIR_NAME . '/essential.css');
    }

    /**
     * @return array|false|string
     */
    public static function getEssentialJsInline() {
        return file_get_contents(__DIR__ . self::BASE_ASSET_PATH . '/' . self::ASSET_DIR_NAME . '/essential.js');
    }

    /**
     * @param string $bundleName
     * @param string $bundleFileName
     * @return string
     */
    public static function getBundlePath(string $bundleDirName, string $bundleFileName) : string {
        $assetPath = self::getExternalBundlePath(self::BASE_ASSET_PATH, self::ASSET_DIR_NAME, $bundleFileName, $bundleDirName . '/');
        if (!empty($assetPath)) return $assetPath;

        $assetPath = self::getExternalBundlePath(self::BASE_ASSET_PATH, self::ASSET_DIR_NAME, $bundleFileName);
        if (!empty($assetPath)) return $assetPath;

        return '';
    }

    /**
     * @param string $basePath
     * @param string $assetDirName
     * @param string $bundleFileName
     * @param string $bundleDir
     * @return string
     */
    private static function getExternalBundlePath(string $basePath, string $assetDirName, string $bundleFileName, string $bundleDir = '') : ?string {
        preg_match('/(.+)(\..+$)/', $bundleFileName, $baseFileMatches);
        if (count($baseFileMatches) !== 3) throw new \UnexpectedValueException('A total of 3 matches were expected');
        $baseFileName = $baseFileMatches[1];
        $baseFileExt = $baseFileMatches[2];

        $files = glob(__DIR__ . $basePath . '/' . $assetDirName . '/' . $bundleDir . $baseFileName . '*' . $baseFileExt);

        if (empty($files)) return null;

        $indexPattern = str_replace('/', '\/', '/' . $assetDirName . '/' . $bundleDir);
        preg_match('/' . $indexPattern . $baseFileName . '(_[0-9a-z]+)?' . $baseFileExt . '/', $files[0], $matches);
        if (!empty($matches)) return $matches[0];

        return null;
    }
}

As you can see, we've removed the custom suffixes from the asset filenames in the getEssentialCssInline and getEssentialJsInline methods, and we've also added a couple of new methods that will deal with bundling.

The getBundlePath() method accepts the directory name ($bundleDirName) and filename ($bundleFileName) of a bundle that we want to reference. The task of actually locating the bundle and returning its path relative to our public asset directory (/assets/ in this case) is then handed off to the private getExternalBundlePath() method.

Once we have these paths, we can render them into the Twig templates so that they can be used in the front-end of our project.

Open the src/Controller/HomeController.php file, and amend the index() method so that it looks like the following:

/**
* @Route("/", name="home")
*/
public function index()
{
    return $this->render(
        'home/index.html.twig',
        [
            'essential_styles' => ClientBundleHelpers::getEssentialCssInline(),
            'essential_scripts' => ClientBundleHelpers::getEssentialJsInline(),
            'bundled_styles' => ClientBundleHelpers::getBundlePath('homePage', 'index.css'),
            'bundled_scripts' => ClientBundleHelpers::getBundlePath('homePage', 'index.js')
        ]
    );
}

New Twig templates

We'll also need to update our Twig templates, so open templates/base.html.twig, and amend the contents so that it looks like the following:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        {% if bundled_styles is defined and bundled_styles is not empty %}
            <link rel="stylesheet" href="{{ bundled_styles }}" />
        {% endif %}
        {% if essential_styles is defined and essential_styles is not empty %}
            <style>
                {{ essential_styles|raw }}
            </style>
        {% endif %}
    </head>
    <body>
        {% block body %}{% endblock %}
        {% if bundled_scripts is defined and bundled_scripts is not empty %}
            <script src="{{ bundled_scripts }}"></script>
        {% endif %}
        {% if essential_scripts is defined and essential_scripts is not empty %}
            <script>
                {{ essential_scripts|raw }}
            </script>
        {% endif %}
    </body>
</html>

Notice the new sections on lines 6 to 8 and 17 to 19. They're there to render the paths that we returned from the calls to the getBundlePath methods that we made in src/Controller/HomeController.php.

We also need to remove the hard coded paths to the bundles in our home/index.html.twig template, so open that file and remove the blocks so that the contents look like this:

{% extends 'base.html.twig' %}

{% block body %}
    <div class="example-wrapper">
        <h1>Symfony 4: using ParcelJs with Twig templates</h1>
        <p>Hello from Hackerbox.io</p>
    </div>
{% endblock %}

If you now run the project in development mode, using the composer run-local-dev command, then the public/assets directory should be cleared, the contents should be replaced with new asset files containing bundles and inline scripts, and you should be able to see the results by pointing your browser at http://localhost:8000.

New production scripts

That's all well and good, but it doesn't give us much more than we had before. Let's add some changes to the scripts for our production build so that we can see cache-busting in action.

Open up the package.json file, and alter the contents of the scripts section so that it appears as follows.

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build_styles_prod": "parcel build styles/homePage/*.css --out-dir public/assets --no-source-maps",
    "build_styles_dev": "parcel watch styles/homePage/*.css --no-hmr --out-dir public/assets --public-url /assets/",
    "build_scripts_prod": "parcel build scripts/homePage/*.js --out-dir public/assets --no-source-maps",
    "build_scripts_dev": "parcel watch scripts/homePage/*.js --no-hmr --out-dir public/assets --public-url /assets/",
    "add_asset_hash": "node utility/addAssetHashes.js",
    "build_essential_styles_prod": "parcel build styles/essential.css --out-dir public/assets --no-source-maps",
    "build_essential_styles_dev": "parcel watch styles/essential.css --no-hmr --out-dir public/assets --no-source-maps",
    "build_essential_scripts_prod": "parcel build scripts/essential.js --out-dir public/assets --no-source-maps",
    "build_essential_scripts_dev": "parcel watch scripts/essential.js --no-hmr --out-dir public/assets --no-source-maps",
    "build_assets_prod": "find public/assets/* | xargs rm -r; npm run build_styles_prod; npm run build_scripts_prod; npm run add_asset_hash; npm run build_essential_styles_prod & npm run build_essential_scripts_prod",
    "build_assets_dev": "find public/assets/* | xargs rm -r; npm run build_essential_styles_dev & npm run build_essential_scripts_dev & npm run build_styles_dev & npm run build_scripts_dev"
},

The main change we've made here is the addition of the add_asset_hash script. This is used to call some code that will append MD5 hashes to the names of asset bundles. The MD5 hash is created from the contents of the file itself; the idea being that if the file changes then so will the MD5 hash, and therefore the name of the file, which will help to prevent browsers and intermediary caches storing stale asset content. After all, any awesome new front-end features that we create won't be appreciated if people are using out-of-date copies of our bundles ;)

That script doesn't even exist yet though, so create a directory named utility in your project root, a file inside that directory named addAssetHashes.js, and then inside that file paste the following code:

const crypto = require('crypto');
const fs = require('fs');
const path = { resolve, dirname, basename } = require('path');
const { readdir, stat } = require('fs').promises;
const directories = { public: 'public', assets: 'assets' };

const buildHashData = async (assetPath) => {
    // read the contents of a specified file
    const contents = await fs.promises.readFile(assetPath, 'binary');
    // create an MD5 hash of the contents of the file
    const hash = crypto.createHash('md5').update(contents).digest('hex');

    // return an object containing both the original asset path, and also the asset path with an MD5 hash inserted into the filename
    return {
        assetPath,
        hashedAssetPath: assetPath.replace(/(^.*)(\.(js|css))$/i, `$1_${hash}$2`)
    };
}

async function* getFilesToHash(rootPath) {
    // get the names of all the files and directories that are inside a specified directory
    const fileNames = await readdir(rootPath);
    for (const fileName of fileNames) {
        // build a complete path to the file
        const fullPath = resolve(rootPath, fileName);
        // if the file is actually a directory, then we call this function recursively and yield the results so that we can
        // access the files inside
        if ((await stat(fullPath)).isDirectory()) yield* getFilesToHash(fullPath);
        // if the file is in the root of the assets directory, and it's not the "main.xxxxxxxx.js" file (which is created by Parcel
        // if its code-splitting functionality is in use) then simply yield the full path of that file
        if (!(basename(dirname(fullPath)) === directories.assets && new RegExp(/main\..+$/).test(fileName))) yield fullPath;
    }
}

// this self executing anonymous function begins the process
(async () => {
    // for each result returned asynchronously from the getFilesToHash function.......
    for await (const assetPath of getFilesToHash(path.join(directories.public, directories.assets))) {
        // if the asset doesn't have a .css or ,js extension then ignore it
        if (!assetPath.match(/^.*.(js|css)$/i)) continue;

        // get the asset's current path / filename, and also that same path / filename with an MD5 hash included in the filename
        const hashData = await buildHashData(assetPath);

        // rename the existing asset so that its filename now includes the MD5 hash
        fs.rename(hashData.assetPath, hashData.hashedAssetPath, function(err) {
            if (err) return console.log(`ERROR when adding hashes to asset filenames: ${err}`);
            console.log(`Renamed asset file ${hashData.assetPath} to ${hashData.hashedAssetPath}`);
        });
    }
})();

Cache-busting in action

Run the project in production mode using the composer run-local-prod command, and you should be able to see the results by pointing your browser at http://localhost:8000. If you look at the page source, you should be able to see that MD5 hashes have been added to the names of the asset bundles, and this should also be evident in the files that appear in the asset directory.

If you alter the contents of one of the bundled files and the re-run the composer run-local-prod command, you should see that the MD5 hash that is appended to the corresponding bundle name has been altered too.

Code splitting

Parcel also allows for the dynamic loading of Js modules. This feature is particularly useful when you'd rather load client-side features only where specific conditions are met, or when a certain event occurs (e.g. a button is clicked). In this way, it's possible to keep the initial asset bundle size to a minimum, downloading additional pieces of script if and when they are required.

We'll need to install a couple of additional NPM modules before we start, so run the following commands at the terminal:

npm i babel-polyfill --save
npm i async --save-dev

Create two new directories: styles/codeSplitting and scripts/codeSplitting

In the styles/codeSplitting directory, create a file named index.css and paste in the following code:

@import '../main.css';

h2 {
    font-weight: bold;
    color: #00f;
}

In the scripts/codeSplitting directory, create a file named index.js and paste in the following code:

import 'babel-polyfill';

document.getElementById('codeSplittingButton').addEventListener('click', async () => {
        const { Messages } = await import ('../main');
        console.log(Messages.SayHelloDynamically());
    });


Amend the contents of scripts/main.js so that the code looks like:

class Messages {
    static SayHello () {
        return 'Hello from Messages!';
    }

    static SayHelloDynamically () {
        return 'This message was called using a dynamic import!';
    }
}

export { Messages };

Amend the contents of scripts/homePage/index.js so that the code looks like:

import { Messages } from '../main';

console.log(Messages.SayHello());

We'll make a new controller to demonstrate the code-splitting functionality, so create the file src/Controller/CodeSplittingController.php and paste in the following contents:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use App\Controller\Helpers\ClientBundleHelpers;


class CodeSplittingController extends AbstractController
{
    /**
     * @Route("/codesplitting", name="codeSplitting")
     */
    public function index()
    {
        return $this->render(
            'codeSplitting/index.html.twig',
            [
                'essential_styles' => ClientBundleHelpers::getEssentialCssInline(),
                'essential_scripts' => ClientBundleHelpers::getEssentialJsInline(),
                'bundled_styles' => ClientBundleHelpers::getBundlePath('codeSplitting', 'index.css'),
                'bundled_scripts' => ClientBundleHelpers::getBundlePath('codeSplitting', 'index.js')
            ]
        );
    }
}

On line 18 there's a reference to a template file that doesn't exist yet, so create the file templates/codeSplitting/index.html.twig and paste in the following contents:

{% extends 'base.html.twig' %}

{% block body %}
    <div class="example-wrapper">
        <h1>Symfony 4: using ParcelJs with Twig templates</h1>
        <h2>Code Splitting</h2>
        <p>Hello from Hackerbox.io</p>
    </div>

    <div>
        <p>Open the Javascript console on your browser, and then click the button below. You should see a message a appear. That message was called from a dynamic include.</p>
        <button id="codeSplittingButton">Call Code From A Dynamic Import</button>
    </div>

{% endblock %}

Finally, we need to include the components of our code-splitting example into our Parcel build. Here's where I noticed something that further complicates our process though (if we want to use sourcemaps, that is).

I initially wanted to watch the development assets using the same script and Parcel process, using something similar to below:

"build_styles_dev": "parcel watch styles/homePage/*.css styles/codeSplitting/*.css --no-hmr --out-dir public/assets --public-url /assets/",

Unfortunately, this only seems to create the correct sourcemap reference for the asset that is passed as the first argument (styles/homePage/*.css in this case).

We can work around this by creating an additional script (or possibly another parcel watch command within the same script), but it's something that might affect the scalability of the development process.

That's obviously something for you to consider before taking this same route for your own projects, but for now amend the scripts section of the package.json file so that it looks like the following:

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build_styles_prod": "parcel build styles/homePage/*.css styles/codeSplitting/*.css --out-dir public/assets --no-source-maps",
    "build_styles_dev_homePage": "parcel watch styles/homePage/*.css --no-hmr --out-dir public/assets/homePage --public-url /assets/homePage",
    "build_styles_dev_codeSplitting": "parcel watch styles/codeSplitting/*.css --no-hmr --out-dir public/assets/codeSplitting --public-url /assets/codeSplitting",
    "build_scripts_prod": "parcel build scripts/homePage/*.js scripts/codeSplitting/*.js --out-dir public/assets --no-source-maps",
    "build_scripts_dev_homePage": "parcel watch scripts/homePage/*.js --no-hmr --out-dir public/assets/homePage --public-url /assets/homePage",
    "build_scripts_dev_codeSplitting": "parcel watch scripts/codeSplitting/*.js --no-hmr --out-dir public/assets/codeSplitting --public-url /assets/codeSplitting",
    "add_asset_hash": "node utility/addAssetHashes.js",
    "build_essential_styles_prod": "parcel build styles/essential.css --out-dir public/assets --no-source-maps",
    "build_essential_styles_dev": "parcel watch styles/essential.css --no-hmr --out-dir public/assets --no-source-maps",
    "build_essential_scripts_prod": "parcel build scripts/essential.js --out-dir public/assets --no-source-maps",
    "build_essential_scripts_dev": "parcel watch scripts/essential.js --no-hmr --out-dir public/assets --no-source-maps",
    "build_assets_prod": "find public/assets/* | xargs rm -r; npm run build_styles_prod; npm run build_scripts_prod; npm run add_asset_hash; npm run build_essential_styles_prod & npm run build_essential_scripts_prod",
    "build_assets_dev": "find public/assets/* | xargs rm -r; npm run build_styles_dev_homePage & npm run build_styles_dev_codeSplitting & npm run build_scripts_dev_homePage & npm run build_scripts_dev_codeSplitting & npm run build_essential_styles_dev & npm run build_essential_scripts_dev"
  },

Our changes in action

At this point, you should be able to start the project in production mode by running the following command:

composer run-local-prod

After this command has run, look in the public/assets directory, and you should see that the asset files have once again been moved into the corresponding homePage and codeSplitting directories, and that they have MD5 hashes embedded in their names. You should see the essential asset files in the root as before, and a main.xxxxxxxx.js file that Parcel uses for code-splitting.

Point your browser at http://localhost:8000 and you should see from the rendered styles and the JS console output that our assets have been created correctly.

Check the page source, and you see that the essential styles have been embedded and minified, and that the Javascript and CSS asset references have MD5 hashes appended to their names. For example:

<link rel="stylesheet" href="/assets/homePage/index_db3ba167220cc446fc83009d305ecd1d.css" />

....and....

<script src="/assets/homePage/index_3a1d5b72e7e7a1c9e2b2ce3741995c0f.js"></script>

Next, navigate to http://localhost:8000/codesplitting

Check you page source as before, but also ensure that you have your Network Tab open.

Click the Call Code From A Dynamic Import button.

The Network Tab should show that the main.xxxxxxxx.js file was downloaded on demand, and if you check your Javascript console, you should see that the downloaded script has rendered the message "This message was called using a dynamic import!".

Summary

By this stage, you should hopefully have a functional Symfony 4 project containing some useful examples of the Parcel.js functionality.

In conclusion, I think that you're probably better off using Webpack, or at least waiting for a Twig plugin to be released for Parcel, as opposed to using my workaround, but hopefully this article has been useful nonetheless. Please share a link around if you liked it.

Be sure to do your own research around all the topics discussed here, as I'm fairly new to PHP myself and this is quite an early iteration of my Symfony / Parcel / Twig solution.

If you liked that article, then why not try:

Using Redis as a cache inside Symfony 4

I've been writing a lot of PHP over the last few months, and have been getting a decent amount of experience with the Symfony framework. In my Symfony 4 project, I wanted to swap out the default memory caching for a Redis database, and thought that it might be helpful to others if I shared my solution. This is only a very simple example, but it should illustrate the basic steps that I took to get Redis caching up and running.

  • php
  • symfony
  • twig
  • javascript
  • parcel