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:
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:
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:
1framework: 2 asset_mapper: 3 # The paths to make available to the asset mapper. 4 paths: 5 - assets/
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>
):
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:
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:
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:
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:
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:
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:
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:
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:
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:
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! π
β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:
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:
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:
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:
- Detecting when a component was rendered on the page, which can be done by listening to the PreCreateForRenderEvent
- Making the AssetMapper/ImportMap understand that a component's CSS could be included on the page without passing through JavaScript
- 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>
:
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
:
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}