A Better Architecture for Your Symfony UX Twig Components
π«π· 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:
<twig:Alert type="danger"> An error has occurred </twig:Alert>
Following the documentation, the architecture of our Alert
component would look like this:
βββ assets βΒ Β βββ controllers βΒ Β βΒ Β βββ alert_controller.js βΒ Β βββ styles βΒ Β βββ alert.css (or app.css) βββ src βΒ Β βββ Twig βΒ Β βΒ Β βββ Components βΒ Β βΒ Β βββ Alert.php βββ templates βββ components βββ 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:
βββ component βΒ Β βββ Alert βΒ Β βββ Alert.php βΒ Β βββ alert.css βΒ Β βββ alert.html.twig βΒ Β βββ alert_controller.js
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:
mkdir component
βConfiguring the Composer Autoloader
Since this new folder is not under src/
, we need to configure Composer and add a new autoload
entry:
// ... "autoload": { "psr-4": { {- "App\\": "src/"-} {+ "App\\": "src/",+} {+ "App\\Component\\": "component/"+} } }, // ...
βConfiguring the Symfony Container
Similarly for the Symfony container, we need to make it aware of this component/
folder:
# ... {+ App\Component\:+} {+ resource: '../component/'+}
βInstalling and Configuring Dependencies
Let's start by installing the necessary dependencies. We will need Symfony AssetMapper, Twig, StimulusBundle, and Symfony UX Twig Components:
composer 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:
framework: asset_mapper: # The paths to make available to the asset mapper. paths: - assets/
twig_component: anonymous_template_directory: 'components/' defaults: # Namespace & directory for components App\Twig\Components\: 'components/'
- and this block of code in the
templates/base.html.twig
file (before the</head>
):
{% block javascripts %} {% block importmap %}{{ importmap('app') }}{% endblock %} {% 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:
<?php declare(strict_types=1); namespace App\Component\Alert; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent('Alert', template: 'Alert/Alert.html.twig')] final class Alert { public ?string $type = null; }
- Twig template:
{% set alert_style = cva({ base: 'alert', variants: { type: { 'success': 'alert--success', 'info': 'alert--info', 'warning': 'alert--warning', 'danger': 'alert--danger', } } }) %} <div{{ attributes.defaults({'data-controller': 'alert'}).without('class') }} class="{{ alert_style.apply({type}, attributes.render('class')) }}" > {% block content '' %} </div>
- CSS styles:
.alert { --alert-color: #818182; color: color-mix(in srgb, var(--alert-color) 50%, black); background-color: color-mix(in srgb, var(--alert-color) 30%, white); padding: 15px; border-radius: 5px; } .alert--success { --alert-color: #2cbf50; } .alert--danger { --alert-color: #d13242; } .alert--warning { --alert-color: #cf9c05; } .alert--info { --alert-color: #15b0c6; }
- Stimulus controller:
import {Controller} from "@hotwired/stimulus"; import './Alert.css'; export default class extends Controller { connect() { console.log('Alert controller connected!'); } }
β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:
twig_component: anonymous_template_directory: 'components/' defaults: # Namespace & directory for components App\Twig\Components\: 'components/' {+ 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:
{% extends 'base.html.twig' %} {% block body %} <div style="display: grid; gap: 10px"> <twig:Alert>This is a default message.</twig:Alert> <twig:Alert type="success">This is a success message.</twig:Alert> <twig:Alert type="info">This is an info message.</twig:Alert> <twig:Alert type="warning">This is a warning message.</twig:Alert> <twig:Alert type="danger">This is a danger message.</twig:Alert> </div> {% 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:
twig: file_name_pattern: '*.twig' {+ paths:+} {+ - '%kernel.project_dir%/component'+} when@test: twig: 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:
framework: asset_mapper: # The paths to make available to the asset mapper. paths: - assets/ {+ - component/+}
And similarly for StimulusBundle, which requires an absolute path:
{+stimulus:+} {+ controller_paths:+} {+ - '%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:
import { Controller } from "@hotwired/stimulus"; {-import './Alert.css';-} {+import('./Alert.css');+} export default class extends Controller { connect() { console.log('Alert controller connected!'); } }
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:
<script type="importmap"> { "imports": { "app": "/assets/app-252595b8e73750f20f965f60c9ddb057.js", "/assets/bootstrap.js": "/assets/bootstrap-c423b8bbc1f9cae218c105ca8ca9f767.js", "/assets/styles/app.css": "data:application/javascript,", "@symfony/stimulus-bundle": "/assets/@symfony/stimulus-bundle/loader-e1ee9ace0562f2e6a52301e4ccc8627d.js", "@hotwired/stimulus": "/assets/vendor/@hotwired/stimulus/stimulus.index-b5b1d00e42695b8959b4a1e94e3bc92a.js", "/assets/@symfony/stimulus-bundle/controllers.js": "/assets/@symfony/stimulus-bundle/controllers-45d15ea38841e6394b6ce7a4de5fed1f.js", "/assets/Alert/alert_controller.js": "/assets/Alert/alert_controller-75e28941942c87874bb05b336a3d7f53.js", "/assets/Alert/Alert.css": "data:application/javascript,document.head.appendChild(Object.assign(document.createElement('link'),{rel:'stylesheet',href:'/assets/Alert/Alert-eb07d1eb98abd4138db0be29530797ef.css'}))" } } </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'}))
.
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:
import './bootstrap.js'; /* * Welcome to your app's main JavaScript file! * * This file will be included onto the page via the importmap() Twig function, * which should already be in your base.html.twig. */ import './styles/app.css'; {+import('../component/Alert/Alert.css');+} console.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:
parameters: app.ui_components.dir: '%kernel.project_dir%/component' services: _defaults: autowire: true autoconfigure: true App\Component\: resource: '%app.ui_components.dir%' twig: paths: - '%app.ui_components.dir%' framework: asset_mapper: paths: - '%app.ui_components.dir%' stimulus: controller_paths: - '%app.ui_components.dir%' twig_component: defaults: 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>
:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>{% block title %}Welcome!{% endblock %}</title> <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>"> {% block stylesheets %} {% endblock %} <!-- Importmap: placeholder --> </head> <body> {% block body %}{% endblock %} {% block javascripts %} <!-- Importmap: begin --> {% block importmap %}{{ importmap('app') }}{% endblock %} <!-- Importmap: end --> {% endblock %} </body> </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
:
<?php declare(strict_types=1); namespace App\EventListener; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\ResponseEvent; #[AsEventListener] final readonly class ImportMapResponseListener { private const IMPORTMAP_PLACEHOLDER = '<!-- Importmap: placeholder -->'; private const IMPORTMAP_BEGIN = '<!-- Importmap: begin -->'; private const IMPORTMAP_END = '<!-- Importmap: end -->'; public function __invoke(ResponseEvent $event): void { if (!$event->isMainRequest()) { return; } $response = $event->getResponse(); $content = $response->getContent(); if (!str_contains($content, self::IMPORTMAP_PLACEHOLDER) || !str_contains($content, self::IMPORTMAP_BEGIN) || !str_contains($content, self::IMPORTMAP_END) ) { return; } $importMap = substr( $content, $begin = strpos($content, self::IMPORTMAP_BEGIN) + strlen(self::IMPORTMAP_BEGIN), strpos($content, self::IMPORTMAP_END) - $begin, ); $content = str_replace($importMap, '', $content); $content = str_replace(self::IMPORTMAP_PLACEHOLDER, trim($importMap), $content); $response->setContent($content); } }