Sulu and Symfony UX - part 1

I recently revamped my blog and rebuilt it in Sulu CMS, a modern Symfony based CMS. And since it's Symfony based, I felt it was a nice opportunity to see how well Symfony UX would integrate.

The result... you're looking at it :-).

The Symfony UX initiative is a, still small, ecosystem of JavaScript tools that were adapted for easy integration with Symfony. There are three tools in particular that I enjoy using most.

This blog post will go into using these tools within a Sulu CMS installation.

Encore

This one is foundational, it's a standardized webpack setup for Symfony. It makes webpack more accessible to non-JavaScript developers, without taking away flexibility.

Implementing it within Sulu is relatively easy, but there's a caveat. Sulu has split the website and admin assets into separate directories. So you have to change some paths in the default Encore setup.

Luckily, this is all covered in the Sulu documentation: Using Webpack Encore for your website assets

NOTE: It's possible that your admin interface is now broken, complaining about a missing manifest file. This can be fixed by running bin/console sulu:admin:update-build.

Stimulus

Stimulus is part of the Hotwire approach to building modern web applications. It's a great way of defining JavaScript components, giving you a little bit of structure without taking over. It also integrates nicely with Turbo, initializing components when DOM changes take place.

After installing Encore, the stimulus bridge is enabled by default in your webpack.config.js:

    // enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js)
    .enableStimulusBridge('./assets/website/controllers.json')

There you go! You can now enhance your pages with Stimulus controllers.

Turbo

Turbo is also part of Hotwire, it turns anchor links and forms into AJAX calls, replacing the DOM with the response body, or parts of it. It allows you to potentially make some speed improvements and overall improve the user experience by replacing instead of reloading. For example, on my website, when you click a link in the navigation on the left, an AJAX call is made, the response body is taken and replaces parts of the DOM.

Installing Turbo is pretty easy:

$ composer require symfony/ux-turbo
$ npm install
$ npm run dev-server

There's only one part that you have to manually configure, which is the assets/website/controllers.json, the reason you have to do this manually is explained and discussed here.

{
    "controllers": {
        "@symfony/ux-turbo": {
            "turbo-core": {
                "enabled": true,
                "fetch": "eager"
            }
        }
    },
    "entrypoints": []
}

And that's it, you're now using Turbo!

Turbo Frames

If you look closely at the response that's returned in the AJAX calls on my website, you'll notice that not the entire page is returned, rather a single Turbo Frame. Turbo Frames are a way to decompose a page into separate frames and update these separately. Now the performance benefits on this simple blog website are negligible, since I'm replacing the entire body because both navigation and content needs to be updated. But in a more complex application/website, you can decide to render a lot less in the AJAX response, shaving off server response time.

Let me tell you how I utilized it in my blog. When Turbo does an AJAX request for a Turbo Frame, it will supply a header in this request specifying which frame. I use this header to determine what template to extend in my Twig template, allowing me to render just the frame, without anything around it.

I have two layout templates:

frame.html.twig

<turbo-frame id="body" data-turbo-action="advance">
    {% block body %}
        <section id="wrapper">
            {{ include('includes/sidebar.html.twig') }}

            <main>
                {% block content %}{% endblock %}
            </main>

            {{ include('includes/footer.html.twig') }}
        </section>
    {% endblock %}
</turbo-frame>

page.html.twig

<!DOCTYPE html>
<html lang="{{ app.request.locale|split('_')[0] }}">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    {% block meta %}
        {% include "@SuluWebsite/Extension/seo.html.twig" with {
            "seo": extension.seo|default([]),
            "content": content|default([]),
            "localizations": localizations|default([]),
            "shadowBaseLocale": shadowBaseLocale|default(),
        } %}
    {% endblock %}

    {% block style %}{{ encore_entry_link_tags('app') }}{% endblock %}
    {% block javascripts %}{{ encore_entry_script_tags('app') }}{% endblock %}
</head>
<body>
    <turbo-frame id="body" data-turbo-action="advance">
        {% block body %}
            <section id="wrapper">
                {{ include('includes/sidebar.html.twig') }}

                <main>
                    {% block content %}{% endblock %}
                </main>

                {{ include('includes/footer.html.twig') }}
            </section>
        {% endblock %}
    </turbo-frame>
</body>
</html>

In my article template I have the following logic to switch between these two layouts:

pages/article.html.twig

{% extends app.request.headers.has('turbo-frame') ? 'frame.html.twig' : 'page.html.twig' %}

I quite like the simplicity of this improvement, it doesn't get in the way and gets the job done nicely.

IMPORTANT: As you might notice while clicking around and refreshing using this turbo frame setup, you sometimes get just the turbo-frame response served, even though you expected the entire HTML. This is due to caching. We need to tell the FOSHttpCacheBundle to add the turbo-frame header to the vary header to make sure these responses are cached separately.

config/packages/fos_http_cache.yaml

fos_http_cache:
    cache_control:
        rules:
            -
                match:
                headers:
                    vary: turbo-frame

Conclusion

Sulu CMS and Symfony UX go hand-in-hand. They can compliment each other nicely when you do decide to use Sulu's frontend. If you haven't tried Symfony UX or Sulu CMS, give them a try, separately or together.

Part 2

There's still some stuff left to do. Read part 2 to learn how to make sure the title tag also updates when navigating, and we can improve performance by not returning the navigation again, updating the active state with javascript instead.