During the past weeks, we've been upgrading Symfony websites like symfony.com, live.symfony.com and certification.symfony.com to use AssetMapper instead of Webpack Encore. This blog posts explains how we did it.

Initial Situation: Webpack Encore

All Symfony websites used Webpack Encore to manage their assets. Encore is a small layer on top of Webpack to make it easier to configure and manage. This setup worked well for us, but it has some drawbacks.

First, it requires installing, configuring and maintaining a non-trivial JavaScript setup with tools like Node.js, Babel, Webpack, etc. Thanks to Webpack Encore, a lot of this complexity is transparent to you.

However, when deploying the application, we had to replicate this system in production. We use Platform.sh to deploy our websites, where the building of assets with Webpack Encore is automated. But having to build assets on every deploy makes the deployment slower (and consumes resources unnecessarily).

The second important drawback of Webpack Encore is that you need to build assets before using them (with commands like npx encore production). When developing the application in your local machine this is cumbersome, even if you can run a command like npx encore dev --watch to rebuild assets automatically when you change anything on them.

After the introduction of AssetMapper in Symfony 6.3 and the AssetMapper improvements in Symfony 6.4, we decided to give AssetMapper a try. The promise sounded almost magical: you could have all the good parts of Webpack Encore and none of the bad parts and have them in a much simpler way.

Unlearning

Before continuing, it's important to unlearn some things. Many web developers agree on this: "a best practice is to use a bundler (like Webpack) to combine and minimize assets before serving them". This is no longer true.

  • You don't need a bundler to do great and complex things in JavaScript involving imports. All browsers support imports natively now. Read more about this
  • You don't need to minify the contents of web assets (to remove white spaces, transform CSS properties to optimize them, etc.) Compressing the web assets in your server before serving them gives you almost the same result. Read more about this
  • You don't need to combine assets into a big single asset to reduce the number of HTTP requests. When using HTTP/2 and HTTP/3, it's fine to send tens of asset files to the browser. You can still get a 100/100 speed score. Read more about this

Now we're ready to do the actual migration to AssetMapper.

Migrating from Webpack Encore to AssetMapper

For a real and full example of migrating an application from Webpack Encore to AssetMapper, check out this pull request in the Symfony Demo repository.

Installation

First, install AssetMapper as explained in the AssetMapper documentation (for us, it was just running composer require symfony/asset-mapper command and checking the changes done by the associated recipe).

The importmap.php File

Instead of using the webpack.config.js file, in AssetMapper you use a file called importmap.php at the root directory of the project. This file tells which assets are used in your application.

Instead of creating this file by hand, you run the importmap:require command and add all the needed assets. In our case, we took the list of assets in the package.json file and added the dependencies one by one with the importmap:require command:

1
2
3
4
$ php bin/console importmap:require bootstrap
$ php bin/console importmap:require clipboard
$ php bin/console importmap:require tom-select
# ...

Keep in mind that you don't have to install the dependencies that were used only by Webpack Encore and not your application (in our case: @babel/core, @babel/preset-env, file-loader, webpack, etc.)

If you open the importmap.php after installing the assets, you'll see something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

return [
    // ...
    'bootstrap/dist/css/bootstrap.min.css' => [
        'version' => '5.3.2',
        'type' => 'css',
    ],
    'tom-select' => [
        'version' => '2.3.1',
    ],
    'clipboard' => [
        'version' => '2.0.11',
    ],
    // ...

This file only defines the versions of the assets. To actually download the assets, you must run the importmap:install command, which downloads the assets to the <your-project>/assets/vendor/ directory. This is like the old node_modules/ directory, but if you compare both, you'll see that the new assets vendor only contains a few files. This is because AssetMapper downloads the full compiled CSS/JavaScript files, instead of all the source files needed to build them.

The Entrypoints

In the webpack.config.js file you defined the Webpack Encore entries, which are the final asset files that will be generated when building assets:

1
2
3
4
5
6
Encore
    // ...
    .addEntry('app', './assets/app.js')
    .addEntry('admin', './assets/admin.js')
    .addEntry('schedule', './assets/conference-schedule.js')
;

In AssetMapper there's a similar concept called entrypoints. When upgrading to AssetMapper, we mapped the entries into entrypoints 1-to-1. You need to do this manually by editing the importmap.php file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

return [
    'app' => [
        'path' => './assets/app.js',
        'entrypoint' => true,
    ],
    'admin' => [
        'path' => './assets/admin.js',
        'entrypoint' => true,
    ],
    'schedule' => [
        'path' => './assets/conference-schedule.js',
        'entrypoint' => true,
    ],
    'bootstrap/dist/css/bootstrap.min.css' => [
        'version' => '5.3.2',
        'type' => 'css',
    ],
    // ...

We realized that we probably have too many entrypoints. This is because, in the past, we wanted to load on each page the smallest amount of CSS/JavaScript possible. So, we created an entry for each significant page and loaded only what that page needed. Nowadays, thanks to the massive compression of web assets, this is not worth it for normal pages. So, we'll remove some entrypoints in the future and leave only the entrypoints of very complex pages.

Changes in Asset Files

Your files inside the assets/ directory don't require many changes, but you might need to do some tweaks. Now, every time you import files, you must include the file extension:

1
2
3
4
5
// assets/some-file.js
- import './bootstrap';
- import './code';
+ import './bootstrap.js';
+ import './code.js';

Another tweak we made is in the way we load the Bootstrap CSS styles. Before, we used to pick and choose which parts of Bootstrap to use:

1
2
3
4
5
6
7
8
// assets/styles/app.scss
@import "~bootstrap/scss/root";
@import "~bootstrap/scss/reboot";
@import "~bootstrap/scss/utilities";
@import "~bootstrap/scss/helpers/visually-hidden";
@import "~bootstrap/scss/images";
@import "~bootstrap/scss/breadcrumb";
// ...

We did this to optimize assets as much as possible and only include the parts that we're going to use. Now we include the entire Bootstrap CSS styles:

1
2
3
// assets/app.js
import 'bootstrap/dist/css/bootstrap.min.css';
// ...

This is an acceptable trade-off for us because we use most of the Bootstrap CSS styles and because, thanks to compression, browsers only need to download 34 KB to get the full Bootstrap styles.

Changes in Templates

Our templates followed a very typical hierarchy and loaded Webpack Encore entries like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{# templates/base.html.twig #}
{% block stylesheets %}
    {{ encore_entry_link_tags('app') }}
{% endblock %}

{% block javascripts %}
    {{ encore_entry_script_tags('app') }}
{% endblock %}

{# templates/conference/schedule.html.twig #}
{% extends 'base.html.twig' %}

{% block stylesheets %}
    {{ parent() }}
    {{ encore_entry_link_tags('schedule') }}
{% endblock %}

{% block javascripts %}
    {{ parent() }}
    {{ encore_entry_script_tags('schedule') }}
{% endblock %}

All pages need the app.css and app.js assets, so we load the app entry in the base.html.twig template. Then, if any template needs additional assets, they load other Webpack Encore entries using Twig template inheritance.

Now, the same templates look like this:

1
2
3
4
5
6
7
8
9
10
11
{# templates/base.html.twig #}
{% block importmap %}
    {{ importmap('app') }}
{% endblock %}

{# templates/conference/schedule.html.twig #}
{% extends 'base.html.twig' %}

{% block importmap %}
    {{ importmap(['app', 'schedule']) }}
{% endblock %}

Now it looks much simpler, because you only need to importmap() one entrypoint to get both the CSS and JavaScript files. However, there's a drawback. You can't use template inheritance to add more entrypoints to the page. You can only call importmap() once per page. So, the base entrypoints used by all pages (e.g. app) must be repeated in all the importmap() calls of the templates that add new entrypoints.

Saas and CSS

We used Sass in all the applications instead of pure CSS. Sass served us well during these years, but we no longer need it. The only two Sass features that we used were:

In most websites, we refactored the Sass files into pure CSS files. Given that CSS nesting is still at 80% browser support, we removed nested selectors and we'll add them back again when support is more widespread.

In another website, we kept the .scss files because moving to CSS was too much work. In that project, we installed the symfonycasts/sass-bundle which makes it easy to use Sass with Symfony's AssetMapper Component (it doesn't require using Node.js).

Tailwind CSS

In some website, we use Tailwind CSS instead of Bootstrap. Luckily, there's a symfonycasts/tailwind-bundle that takes care of everything for you. This bundle doesn't require Node.js either and it's based on a binary file that compiles the Tailwind CSS styles.

Changes in CI and Deployment

This was one of the easiest parts of the upgrade process. You only need to update the commands run to build/compile the assets:

1
2
3
4
5
6
7
8
// .github/workflows/tests.yaml
  - name: Build and compile assets
    run: |
-     npm install
-     npx encore production
+     php bin/console importmap:install
+     php bin/console sass:build
+     php bin/console asset-map:compile

The deployment process required zero changes. Thanks to its tight integration with Symfony, Platform.sh already detects if you are using AssetMapper, SassBundle and/or TailwindBundle and runs the required commands for you.

Conclusion

We're extremely happy with the upgrade to AssetMapper. It feels like cheating because you end up with the same results you'd get with Webpack Encore, yet it requires only a fraction of the effort.

Thanks to the removal of files like package.json, the Pull Requests resulted in the removal of a lot of lines of code:

  • live.symfony.com: +981 lines added, -9,971 lines removed
  • certification.symfony.com: +718 added, −10,519 removed
  • symfony.com: +1,105 added, −10,088 removed

And these stats don't include the removal of the node_modules/ directory, which will save you a ton of disk space.

Another nice improvement is that changes in assets are now propagated instantaneously to the browser: make a change, save files, reload page. No more waiting to asset building; except if you use Sass or Tailwind CSS, in which case you need to run sass:build --watch or tailwind:build --watch to rebuild assets when they change.

Finally, AssetMapper keeps the great performance of the websites. Even if we now serve many more CSS/JavaScript assets to the browsers, the performance score is close to 100/100 in most pages.

AssetMapper is the present and future of asset management in Symfony applications. If you can convince your boss/client to get the resources needed to upgrade your own projects to AssetMapper, don't think twice and upgrade as soon as possible.

Published in #Symfony