Stimulus and AssetMapper
Symfony UX in one page: Stimulus controllers attached with data attributes, served by AssetMapper with no build step.
php bin/console importmap:require @hotwired/stimulusSymfony UX is a set of PHP packages that each ship a small piece of JavaScript. You install them with Composer, and the JavaScript arrives with the PHP. There is no separate frontend project to maintain.
Stimulus
Stimulus is a small JavaScript framework. You write a class, save it as assets/controllers/name_controller.js, and attach it to an element with data-controller="name". Stimulus calls that class's connect() method when the element appears in the page and disconnect() when it leaves. The Twig helper {{ stimulus_controller('name') }} renders the same attribute. The HTML stays server-rendered; the JavaScript decorates it.
AssetMapper
AssetMapper serves the assets/ directory directly. Each file is published under a hashed filename for cache busting, and bare module names such as @hotwired/stimulus are resolved by an importmap, a browser feature that tells the module loader where a name points. The map is declared in importmap.php and rendered by {{ importmap('app') }} in your base template. In Symfony 8.1 this is the recommended asset system, and no Node.js or build step is involved. Webpack Encore still works and is now the legacy bundler option.
What trips people up
Relative imports need the file extension: import './duck.js', not import './duck'. The browser resolves the path literally. Third-party packages must be registered with importmap:require first, or the browser reports that it cannot resolve the module specifier. For production, asset-map:compile writes the versioned files into public/assets/.
Example
<?php
// importmap.php
declare(strict_types=1);
// This file is written by `php bin/console importmap:require` and read by
// AssetMapper when it renders {{ importmap('app') }}.
//
// An entry with 'path' is one of your own files under assets/.
// An entry with 'version' is a package downloaded into assets/vendor/
// by `php bin/console importmap:install`.
//
// 'entrypoint' => true means the file is executed on page load; any other
// entry is only fetched when something imports it.
return [
'app' => [
'path' => './assets/app.js',
'entrypoint' => true,
],
// A second entrypoint, loaded only on the pages that ask for it with
// {{ importmap(['app', 'checkout']) }}.
'checkout' => [
'path' => './assets/checkout.js',
'entrypoint' => true,
],
// Imported in JavaScript as: import { Controller } from '@hotwired/stimulus';
'@hotwired/stimulus' => [
'version' => '3.2.2',
],
];
When to use it
- Add a copy-to-clipboard button to a server-rendered page without installing Node.js.
- Pull a date picker from npm with importmap:require and import it in assets/app.js.
- Serve versioned CSS and images from assets/ by running asset-map:compile at deploy time.
- Attach behaviour to table rows that are inserted into the page after it loaded.
More examples
A Stimulus controller with values and targets
Every controller file in assets/controllers/ is registered automatically by its filename, so clipboard_controller.js becomes the controller named clipboard. Values and targets are how the server passes data in and how the controller finds the elements it owns, which means the class never needs a CSS selector or an id. Reach for this whenever the behaviour is purely local to one element.
// assets/controllers/clipboard_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
// Declaring a value here makes data-clipboard-text-value="..."
// readable as this.textValue, already cast to a string.
static values = { text: String };
// Declaring a target makes data-clipboard-target="status"
// readable as this.statusTarget.
static targets = ['status'];
connect() {
this.statusTarget.textContent = '';
}
async copy(event) {
event.preventDefault();
await navigator.clipboard.writeText(this.textValue);
this.statusTarget.textContent = 'Copied';
}
}
The same markup, longhand and shorthand
These two blocks produce the same HTML. stimulus_controller() takes the controller name and a map of values, stimulus_action() takes controller, method and event, and stimulus_target() takes controller and target name. The helpers escape values and handle naming, so they are worth using in your own templates, but you need to recognise the raw attributes because that is what reaches the browser and what you will see in other people's code and in bundle documentation.
{# templates/article/show.html.twig #}
{# Longhand: the data attributes Stimulus actually reads. #}
<div data-controller="clipboard"
data-clipboard-text-value="{{ url('app_article_show', {id: article.id}) }}">
<button data-action="click->clipboard#copy">Copy link</button>
<span data-clipboard-target="status"></span>
</div>
{# Shorthand: the Twig helpers generate exactly those attributes. #}
<div {{ stimulus_controller('clipboard', {
text: url('app_article_show', {id: article.id})
}) }}>
<button {{ stimulus_action('clipboard', 'copy', 'click') }}>Copy link</button>
<span {{ stimulus_target('clipboard', 'status') }}></span>
</div>
Telling AssetMapper which directories to map
Anything inside a mapped path is publicly reachable once it is referenced, so this file is also a security boundary: do not map a directory that holds anything private. Run php bin/console debug:asset-map to list every file AssetMapper can see and the logical path you use in asset() or in an import. You edit this file when you add a second asset directory, or when a package ships assets you want to import by name.
# config/packages/asset_mapper.yaml
framework:
asset_mapper:
# Every file under these paths gets a public, versioned URL.
paths:
- assets/
# Sass sources are compiled elsewhere; do not publish them.
excluded_patterns:
- '*/*.scss'
Discussion