Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Doing Modern JS Right in your Browser

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

Before we talk about anything related to Symfony, we're going to strip things down to the bare minimum and prove that we can code modern JavaScript, right in our browser.

Directly Loading Some JavaScript

Go directly into the public/ directory and create a new app.js file. To start, just console.log() a message.

This won't be processed by Symfony or anything. In templates/base.html.twig, up here in the javascripts block, though that doesn't make any difference, add a boring <script> tag for this: <script src="{{ asset('app.js') }}">. I am using the asset() function... but that's not doing anything either.

... lines 1 - 2
... lines 4 - 14
{% block javascripts %}
<script src="{{ asset('app.js') }}"></script>
... line 17
{% endblock %}
... lines 20 - 69

Ok, head to the browser, open up your Console and... refresh. There's the log! It's snooze-worthy, but working.

Writing Modern JavaScript

Time to make things interesting! Back in app.js, copy the mix name. Let's create a class: class MixedVinyl, with a constructor and some properties. This uses the class syntax introduced in ES6, or ECMAScript 6... basically version "6" of JavaScript. You'll hear ES6 a lot because most modern features you're used to came from this version - released way back in 2015.

14 lines public/app.js
class MixedVinyl {
constructor(title, year) {
this.title = title;
this.year = year;
describe() {
... line 8
... lines 11 - 14

In the describe() method, I'm leveraging string interpolation - another modern feature from ES6 - to return the string. Below, use this: const - yet another ES6 feature - mix = new MixedVinyl() and pass in the mix name and year. Finally, console.log(mix.describe()).

14 lines public/app.js
class MixedVinyl {
... lines 2 - 6
describe() {
return `${this.title} was released in ${this.year}`;
const mix = new MixedVinyl('Awesome Mix Vol. 1', 2014);

Cool! This is the kind of code I like to write every day. Unfortunately, this is also the kind of code that browsers have historically choked on!

So, normally, we would have a build system like Encore that would read this modern code and rewrite it to old JavaScript... so it would work in our browser. But... tada! It already works in our browser! We don't need to do anything. And that's not just because I'm using a new browser. This is going to work in every browser.

If you're ever unsure, go to https://caniuse.com to check it out. Let's look up "ES6 class". Yup, it's basically supported by everything... except for IE 11, which is dead.

Using "import" in the Browser

But what about the import statement? Copy the class MixedVinyl then create another file directly inside public/ called vinyl.js. Paste this in and then export it: export default class.

12 lines public/vinyl.js
export default class {
constructor(title, year) {
this.title = title;
this.year = year;
describe() {
return `${this.title} was released in ${this.year}`;
... lines 11 - 12

Back over in app.js, import MixedVinyl from and, just like we do in Encore, use the relative path: ./vinyl.js.

5 lines public/app.js
import MixedVinyl from './vinyl.js';
... lines 2 - 5

Though, notice that I am including the .js file extension... which you can do in Encore, but it's not required. More on that later - but this was on purpose.

Importing as a Module

So... does my browser support the import statement? Let's find out! Refresh. Booo:

Cannot use import statement outside a module

Ok, not a "code red" kind of boo, more like a "code orange". Head back to base.html.twig. When you hear the word "module", it's referring to files that leverage export and import. And if you want your JavaScript to be able to use these, you need to load the original file "as a module". It's a simple change. Copy the asset() function and now say <script type="module">. Then, instead of src, inside, we're going to write some JavaScript to import our app.js file.

... lines 1 - 2
... lines 4 - 14
{% block javascripts %}
<script type="module">import '{{ asset('app.js') }}';</script>
... line 17
{% endblock %}
... lines 20 - 69

This may look nutty at first, but... we're simply importing the path to our app.js file. By doing this, app.js will execute exactly like it did before... but as a "module"... which just means that import and export statements "should" work.

Do they? They do! OMG, our browser supports the import statement!

Importing 3rd Party Package URLs

We can even import third-party packages. To find one, I'm going to use my favorite CDN: "jsDelivr". We'll be using this quite a bit throughout the tutorial. But you don't need to use jsDelivr's CDN in your final code. It's a mirror of every NPM package... and so it's a convenient place to find what we need.

Search for the popular "lodash" package. When we select it, it shows us a <script> tag we could use. Click on "ESM", which is short for ECMAScript modules. When you're coding with imports and exports, you want the ESM version of a package: it's a version that properly "exports" modules.

Now check out that script tag:

<script type="module">
import lodash from '[...]'

That looks very similar to the code we have over here! We won't use this exactly, but I am going to copy the URL. Now go back to app.js. To use lodash we can say import _ from and paste that full URL.

6 lines public/app.js
... line 1
import _ from 'https://cdn.jsdelivr.net/npm/lodash@4.17.21/+esm';
... lines 3 - 6

Yes, importing from a full URL is totally allowed. Or we could download this file locally: I'll talk more about that later. Below, let's say _.camelCase() to call one of its methods.

Let's try it! Spin over, refresh, and... look at that!. There's no build system here: we're just playing with files inside the public/ directory. And yet, we're writing modern JavaScript, importing and exporting modules and using a third-party NPM package. That's pretty amazing.

What Features are Missing?

However, there are two remaining problems. First, importing packages using the full URL is annoying. I want to be able to say import from 'lodash' The second problem is asset versioning. To have a performant system, we need the final files downloaded by the browser to have version hashes in their filenames, like app.1234abcd.js. We need this so that we can instruct browsers to perform long-term caching. And we can't get this by creating & serving files directly from public/.

These are precisely the two things that Symfony's new AssetMapper component will help us solve. But I wanted to start with raw JavaScript so that we could see how... most of what we're doing is not solved by Symfony or AssetMapper or AI: it's solved by your browser and the modern web.

Ok, let's delete these two files so I don't get confused... and also remove the import inside of base.html.twig. Don't worry! We'll see all of that code in a different way soon.

Next: Let's install AssetMapper and get it rocking.

Leave a comment!

Login or Register to join the conversation
Marius Avatar

Is there a reason why you chose to add an inline-script to the template instead of loading the JS with <script type="module" src="{{ asset('app.js') }}"></script>?


Hey @Marius

Good observation, I think it was just for teaching purposes. You can use the import statement directly on your browser


Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^4.0", // v4.2.0
        "doctrine/doctrine-bundle": "^2.7", // 2.10.0
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.4
        "doctrine/orm": "^2.12", // 2.15.2
        "knplabs/knp-time-bundle": "^1.18", // v1.20.0
        "pagerfanta/doctrine-orm-adapter": "^4.0", // v4.1.0
        "pagerfanta/twig": "^4.0", // v4.1.0
        "stof/doctrine-extensions-bundle": "^1.7", // v1.7.1
        "symfony/asset": "6.3.*", // v6.3.0
        "symfony/asset-mapper": "6.3.*", // v6.3.0
        "symfony/console": "6.3.*", // v6.3.0
        "symfony/dotenv": "6.3.*", // v6.3.0
        "symfony/flex": "^2", // v2.3.1
        "symfony/framework-bundle": "6.3.*", // v6.3.0
        "symfony/http-client": "6.3.*", // v6.3.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/proxy-manager-bridge": "6.3.*", // v6.3.0
        "symfony/runtime": "6.3.*", // v6.3.0
        "symfony/stimulus-bundle": "^2.9", // v2.9.1
        "symfony/twig-bundle": "6.3.*", // v6.3.0
        "symfony/ux-turbo": "^2.9", // v2.9.1
        "symfony/web-link": "6.3.*", // v6.3.0
        "symfony/yaml": "6.3.*", // v6.3.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.6.1
        "twig/twig": "^2.12|^3.0" // v3.6.1
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.4
        "symfony/debug-bundle": "6.3.*", // v6.3.0
        "symfony/maker-bundle": "^1.41", // v1.49.0
        "symfony/stopwatch": "6.3.*", // v6.3.0
        "symfony/web-profiler-bundle": "6.3.*", // v6.3.0
        "zenstruck/foundry": "^1.21" // v1.33.0