A Better Architecture for Your Symfony UX Twig Components


info

πŸ‡«πŸ‡· Cet article est disponible en franΓ§ais : Une meilleure architecture pour vos Twig Components de Symfony UX

Symfony UX, the JavaScript initiative for Symfony, has long introduced Twig Components. Similar to Vue or React components, these Twig components are easily reusable, isolate their logic, accept props and blocks (equivalent to Vue's slots), and allow composition:

1<twig:Alert type="danger">
2    An error has occurred
3</twig:Alert>

Following the documentation, the architecture of our Alert component would look like this:

 1β”œβ”€β”€ assets
 2β”‚Β Β  β”œβ”€β”€ controllers
 3β”‚Β Β  β”‚Β Β  └── alert_controller.js
 4β”‚Β Β  └── styles
 5β”‚Β Β      └── alert.css (or app.css)
 6β”œβ”€β”€ src
 7β”‚Β Β  β”œβ”€β”€ Twig
 8β”‚Β Β  β”‚Β Β  └── Components
 9β”‚Β Β  β”‚Β Β      └── Alert.php
10└── templates
11    └── components
12        └── alert.html.twig

We notice the PHP class, the Twig template, the JS script, and the CSS styles are completely scattered. Personally, I don't find this very practical.

I would prefer to have all these files in a single place, grouped by component, somewhat like organizing business code by domain. Something like what Angular offers when creating a component:

1β”œβ”€β”€ component
2β”‚Β Β  └── Alert
3β”‚Β Β      β”œβ”€β”€ Alert.php
4β”‚Β Β      β”œβ”€β”€ alert.css
5β”‚Β Β      β”œβ”€β”€ alert.html.twig
6β”‚Β Β      └── alert_controller.js

info

Sources of this article can be found on GitHub at Kocal/twig-components-better-architecture.

​Prerequisites

Our components will be located in a component/ folder at the root of the project:

1mkdir component

​Configuring the Composer Autoloader

Since this new folder is not under src/, we need to configure Composer and add a new autoload entry:

composer.json
  1    // ...
  2    "autoload": {
  3        "psr-4": {
4 -            "App\\": "src/"
5 +            "App\\": "src/",
6 +            "App\\Component\\": "component/"
  7        }
  8    },
  9    // ...

​Configuring the Symfony Container

Similarly for the Symfony container, we need to make it aware of this component/ folder:

config/services.yaml
  1    # ...
  2
3 +    App\Component\:
4 +        resource: '../component/'
  5        

​Installing and Configuring Dependencies

Let's start by installing the necessary dependencies. We will need Symfony AssetMapper, Twig, StimulusBundle, and Symfony UX Twig Components:

1composer require symfony/asset-mapper symfony/asset symfony/twig-pack symfony/stimulus-bundle symfony/ux-twig-component

Thanks to Symfony Flex and the Symfony recipes, the new dependencies should be correctly configured. If not, ensure you have:

config/packages/asset_mapper.yaml
1framework:
2    asset_mapper:
3        # The paths to make available to the asset mapper.
4        paths:
5            - assets/
config/packages/twig_component.yaml
1twig_component:
2    anonymous_template_directory: 'components/'
3    defaults:
4        # Namespace & directory for components
5        App\Twig\Components\: 'components/'
  • and this block of code in the templates/base.html.twig file (before the </head>):
templates/base.html.twig
1{% block javascripts %}
2    {% block importmap %}{{ importmap('app') }}{% endblock %}
3{% endblock %}

​Setting Up and Rendering the Component

​Our Test Component

I'll take the Alert component, which is a quintessential component found in almost every project, and familiar to many.

Here are the files used:

  • PHP class:
component/Alert/Alert.php
 1<?php
 2declare(strict_types=1);
 3
 4namespace App\Component\Alert;
 5
 6use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
 7
 8#[AsTwigComponent('Alert', template: 'Alert/Alert.html.twig')]
 9final class Alert
10{
11    public ?string $type = null;
12}
  • Twig template:
component/Alert/Alert.html.twig
 1{% set alert_style = cva({
 2    base: 'alert',
 3    variants: {
 4        type: {
 5            'success': 'alert--success',
 6            'info': 'alert--info',
 7            'warning': 'alert--warning',
 8            'danger': 'alert--danger',
 9        }
10    }
11}) %}
12
13<div{{ attributes.defaults({'data-controller': 'alert'}).without('class') }} 
14    class="{{ alert_style.apply({type}, attributes.render('class')) }}"
15>
16    {% block content '' %}
17</div>
  • CSS styles:
component/Alert/Alert.css
 1.alert {
 2    --alert-color: #818182;
 3
 4    color: color-mix(in srgb, var(--alert-color) 50%, black);
 5    background-color: color-mix(in srgb, var(--alert-color) 30%, white);
 6    padding: 15px;
 7    border-radius: 5px;
 8}
 9
10.alert--success { --alert-color: #2cbf50; }
11.alert--danger { --alert-color: #d13242; }
12.alert--warning { --alert-color: #cf9c05; }
13.alert--info { --alert-color: #15b0c6; }
  • Stimulus controller:
component/Alert/alert_controller.js
1import {Controller} from "@hotwired/stimulus";
2import './Alert.css';
3
4export default class extends Controller {
5    connect() {
6        console.log('Alert controller connected!');
7    }
8}

​Configuring Twig Components

After creating the component, a similar error might appear:

Could not generate a component name for class "App\Component\Alert\Alert": no matching namespace found under the "twig_component.defaults" to use as a root. Check the config or give your component an explicit name.

This is normal; we used the AsTwigComponent attribute on a class not in a namespace known to Twig Components. We need to modify the configuration to set our namespace:

config/packages/twig_component.yaml
  1twig_component:
  2    anonymous_template_directory: 'components/'
  3    defaults:
  4        # Namespace & directory for components
  5        App\Twig\Components\: 'components/'
6 +        App\Component\: '../component/' 

The error should disappear.

​Configuring Twig

We can now start using our <twig:Alert> component, for example, to test our different variants:

your_template.html.twig
 1{% extends 'base.html.twig' %}
 2
 3{% block body %}
 4    <div style="display: grid; gap: 10px">
 5        <twig:Alert>This is a default message.</twig:Alert>
 6        <twig:Alert type="success">This is a success message.</twig:Alert>
 7        <twig:Alert type="info">This is an info message.</twig:Alert>
 8        <twig:Alert type="warning">This is a warning message.</twig:Alert>
 9        <twig:Alert type="danger">This is a danger message.</twig:Alert>
10    </div>
11{% endblock %}

An error should appear:

Unable to find template "Alert/Alert.html.twig" (looked into: /path/to/app/templates).

This is normal; Twig does not yet know our component/ folder. We need to modify the configuration to add this folder:

config/packages/twig.yaml
  1twig:
  2    file_name_pattern: '*.twig'
3 +    paths:
4 +        - '%kernel.project_dir%/component'
  5
  6when@test:
  7    twig:
  8        strict_variables: true

The error should disappear.

​Configuring AssetMapper and StimulusBundle

Our Alert component is displayed, but we notice that neither the CSS styles nor the Stimulus controller are loaded. These files are not referenced in the page's importmap (<script type="importmap">).

We need to configure AssetMapper by adding our component/ folder:

config/packages/asset_mapper.yaml
  1framework:
  2    asset_mapper:
  3        # The paths to make available to the asset mapper.
  4        paths:
  5            - assets/
6 +            - component/

And similarly for StimulusBundle, which requires an absolute path:

config/packages/stimulus.yaml
1 +stimulus:
2 +    controller_paths:
3 +        - '%kernel.project_dir%/component'

After clearing the Symfony cache, the CSS styles and Stimulus controller should load correctly. Congratulations! πŸ‘

Rendered Alerts

​Issues

This solution is not without its problems, especially concerning the CSS and its loading method.

​Lazy-loading CSS

The CSS is loaded in the <head> via <link rel="stylesheet" href="/assets/Alert/Alert-eb07d1eb98abd4138db0be29530797ef.css">, and it will be the same for each new component.

Depending on your decisions on web performance (more precisely the CrUX), you might prefer:

  • Keeping the current method, but these CSS files may block the display of the site until they are downloaded, parsed, and interpreted.
  • Alternatively, lazy-loading them through a Stimulus controller, but this might cause a FOUC, and thus negatively impact the CLS.

To lazy-load the CSS, we can use the import() syntax which, interpreted by the AssetMapper, will allow the Alert/Alert.css asset to be lazy or not:

component/Alert/alert_controller.js
  1import { Controller } from "@hotwired/stimulus";
2 -import './Alert.css';
3 +import('./Alert.css');
  4
  5export default class extends Controller {
  6    connect() {
  7        console.log('Alert controller connected!');
  8    }
  9}

When refreshing the page, we see the <link rel="stylesheet" href="/assets/Alert/Alert-eb07d1eb98abd4138db0be29530797ef.css"> tag disappear from the <head>, and notice that the importmap has slightly changed:

 1<script type="importmap">
 2{
 3    "imports": {
 4        "app": "/assets/app-252595b8e73750f20f965f60c9ddb057.js",
 5        "/assets/bootstrap.js": "/assets/bootstrap-c423b8bbc1f9cae218c105ca8ca9f767.js",
 6        "/assets/styles/app.css": "data:application/javascript,",
 7        "@symfony/stimulus-bundle": "/assets/@symfony/stimulus-bundle/loader-e1ee9ace0562f2e6a52301e4ccc8627d.js",
 8        "@hotwired/stimulus": "/assets/vendor/@hotwired/stimulus/stimulus.index-b5b1d00e42695b8959b4a1e94e3bc92a.js",
 9        "/assets/@symfony/stimulus-bundle/controllers.js": "/assets/@symfony/stimulus-bundle/controllers-45d15ea38841e6394b6ce7a4de5fed1f.js",
10        "/assets/Alert/alert_controller.js": "/assets/Alert/alert_controller-75e28941942c87874bb05b336a3d7f53.js",
11        "/assets/Alert/Alert.css": "data:application/javascript,document.head.appendChild(Object.assign(document.createElement('link'),{rel:'stylesheet',href:'/assets/Alert/Alert-eb07d1eb98abd4138db0be29530797ef.css'}))"
12    }
13}
14</script>

When /assets/Alert/Alert.css is imported by the JavaScript code, the following code will be executed: data:application/javascript,document.head.appendChild(Object.assign(document.createElement('link'),{rel:'stylesheet',href:'/assets/Alert/Alert-eb07d1eb98abd4138db0be29530797ef.css'})).

warning

Lazy-loading the CSS this way now causes a FOUC.

​Loading Component CSS Without a Stimulus Controller

Another issue is that a component's CSS is detected by the AssetMapper only if this CSS is imported by a JavaScript file. In most cases, this won't be a problem since we would need a Stimulus controller associated with the component, but this is not always true.

To avoid creating a phantom Stimulus controller, we can simply adapt the line import('./Alert.css'); and move it to the assets/app.js file:

assets/app.js
  1import './bootstrap.js';
  2/*
  3 * Welcome to your app's main JavaScript file!
  4 *
  5 * This file will be included onto the page via the importmap() Twig function,
  6 * which should already be in your base.html.twig.
  7 */
  8import './styles/app.css';
9 +import('../component/Alert/Alert.css');
 10
 11console.log('This log comes from assets/app.js - welcome to AssetMapper! πŸŽ‰');

​Further Steps

​Grouping Configuration

Throughout the article, we've configured our component/ path in the configuration files of Twig, AssetMapper, Stimulus, and Twig Components.

We can move everything we've configured into a dedicated file to facilitate maintenance:

config/packages/ui_components.yaml
 1parameters:
 2    app.ui_components.dir: '%kernel.project_dir%/component'
 3
 4services:
 5    _defaults:
 6        autowire: true
 7        autoconfigure: true
 8
 9    App\Component\:
10        resource: '%app.ui_components.dir%'
11
12twig:
13    paths:
14        - '%app.ui_components.dir%'
15
16framework:
17    asset_mapper:
18        paths:
19            - '%app.ui_components.dir%'
20
21stimulus:
22    controller_paths:
23        - '%app.ui_components.dir%'
24
25twig_component:
26    defaults:
27        App\Component\: '%app.ui_components.dir%'

​Including Component CSS Only When Necessary and Without Lazy-loading/FOUC

During my experimentation phase before writing the article, I looked into a way to include a component's CSS only if it is rendered on the page, and without lazy-loading (thus avoiding a FOUC).

It was a lot of tinkering because it required:

  1. Detecting when a component was rendered on the page, which can be done by listening to the PreCreateForRenderEvent
  2. Making the AssetMapper/ImportMap understand that a component's CSS could be included on the page without passing through JavaScript
  3. Making the AssetMapper/ImportMap understand that a component's CSS should be included or not on the page, depending on whether the component was rendered on that page

I managed to achieve this by replacing the \Symfony\Component\AssetMapper\ImportMap\ImportMapGenerator service with a customized service, overriding the getImportMapData() method. It wasn't pretty; this part of the AssetMapper code is really not made to be extended easily: I juggled between decoration and inheritance, method overriding, and tinkering to know if a MappedAsset came from a component or not.

Finally, since the importmap is rendered in the <head>, it was not possible to interfere with its content based on whether a component was rendered on the page or not. To do this, I had to adapt the base Twig template to render the importmap before the </body>:

templates/base.html.twig
 1<!DOCTYPE html>
 2<html>
 3    <head>
 4        <meta charset="UTF-8">
 5        <title>{% block title %}Welcome!{% endblock %}</title>
 6        <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'><text y='1.2em' font-size='96'>⚫️</text><text y='1.3em' x='0.2em' font-size='76' fill='#fff'>sf</text></svg>">
 7        {% block stylesheets %}
 8        {% endblock %}
 9        
10        <!-- Importmap: placeholder -->
11    </head>
12    <body>
13        {% block body %}{% endblock %}
14
15        {% block javascripts %}
16            <!-- Importmap: begin -->
17            {% block importmap %}{{ importmap('app') }}{% endblock %}
18            <!-- Importmap: end -->
19        {% endblock %}
20    </body>
21</html>

However, it is not desirable to have this importmap outside the <head>, we can remedy this by listening to the Symfony\Component\HttpKernel\Event\ResponseEvent:

src/EventListener/ImportMapResponseListener.php
 1<?php
 2declare(strict_types=1);
 3
 4namespace App\EventListener;
 5
 6use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
 7use Symfony\Component\HttpKernel\Event\ResponseEvent;
 8
 9#[AsEventListener]
10final readonly class ImportMapResponseListener
11{
12    private const IMPORTMAP_PLACEHOLDER = '<!-- Importmap: placeholder -->';
13    private const IMPORTMAP_BEGIN = '<!-- Importmap: begin -->';
14    private const IMPORTMAP_END = '<!-- Importmap: end -->';
15    
16    public function __invoke(ResponseEvent $event): void
17    {
18        if (!$event->isMainRequest()) {
19            return;
20        }
21
22        $response = $event->getResponse();
23        $content = $response->getContent();
24
25        if (!str_contains($content, self::IMPORTMAP_PLACEHOLDER)
26            || !str_contains($content, self::IMPORTMAP_BEGIN)
27            || !str_contains($content, self::IMPORTMAP_END)
28        ) {
29            return;
30        }
31        
32        $importMap = substr(
33            $content, 
34            $begin = strpos($content, self::IMPORTMAP_BEGIN) + strlen(self::IMPORTMAP_BEGIN), 
35            strpos($content, self::IMPORTMAP_END) - $begin,
36        );
37        
38        $content = str_replace($importMap, '', $content);
39        $content = str_replace(self::IMPORTMAP_PLACEHOLDER, trim($importMap), $content);
40        
41        $response->setContent($content);
42    }
43}