| name | dynamic |
| description | Customise un élément dynamique Sylius 2.1+ via Stimulus/UX — `*_controller.js`, `stimulus_controller()` Twig, `controllers.json`. Déclenche sur "ajouter du JS Sylius", "controller Stimulus Sylius", "remplacer un controller natif". |
| user_invocable | true |
| allowed-tools | ["Read","Write","Edit","Glob","Grep","Bash(ls:*)","Bash(find:*)","Bash(cat:*)","Bash(git:*)","Bash(symfony:*)","Bash(bin/console:*)","Bash(composer:*)","Bash(vendor/bin/*:*)","Bash(./vendor/bin/*:*)"] |
/dynamic — Customiser les éléments dynamiques Sylius
Tu aides à ajouter, remplacer ou désactiver un comportement JavaScript dans l'admin ou le shop Sylius 2.1+. Depuis la 2.1, Sylius adopte Symfony UX + Stimulus (Hotwired) : chaque bout d'interactivité est un *_controller.js attaché à un élément DOM via data-controller. Le chemin officiel : auto-discovery dans assets/<contexte>/controllers/, binding via le helper Twig stimulus_controller(), injection dans la page via un Twig Hook (pas d'override de template vendor).
Référence officielle : docs.sylius.com/the-customization-guide/customizing-dynamic-elements. Stimulus : symfony.com/bundles/StimulusBundle. Exemple complet : github.com/Jibbarth/SyliusCelebratePlugin.
Détection préalable (obligatoire)
- Lire
composer.json à la racine.
- Vérifier
sylius/sylius ≥ 2.1. En dessous, l'app repose sur l'ancien système d'assets (gulp + Semantic UI côté shop) et ce skill ne s'applique pas. Le PR de migration 2.1 est listé dans la doc : Sylius-Standard#1126.
- Absent / < 2.1 → « Ce skill cible Sylius 2.1+ (Symfony UX + Stimulus). Je ne trouve pas la bonne version. On continue quand même ? »
- Vérifier la chaîne frontend :
ls assets/admin/entrypoint.js assets/shop/entrypoint.js assets/controllers.json
ls assets/admin/controllers.json assets/shop/controllers.json 2>/dev/null
cat package.json | grep -E '"(@symfony/stimulus-bundle|@hotwired/stimulus|@symfony/ux-|webpack-encore)"'
Au minimum : @hotwired/stimulus + un controllers.json à côté de l'entrypoint du contexte visé.
- Si la demande est « changer l'apparence, la couleur, la taille d'un élément » → ce n'est pas du dynamique, basculer sur
/sylius:styles.
- Si la demande est « déplacer, remplacer, injecter un bloc HTML » → c'est du template, basculer sur
/sylius:template pour le Twig Hook, puis revenir ici pour câbler un controller sur le bloc injecté.
- Si la demande est « appel serveur asynchrone avec état réactif » → regarder Symfony UX Live Components (même pattern de binding hookable côté config, annotation
#[AsLiveComponent] côté PHP), mentionner que la réactivité live dépasse le scope Stimulus pur.
Règles fondamentales
- Auto-discovery par défaut, manuel en dernier recours. Tant que le controller vit sous
assets/<contexte>/controllers/ et s'appelle *_controller.js, Symfony UX l'enregistre seul. L'enregistrement manuel (app.register(...) dans bootstrap.js) n'est justifié que pour : controllers hors dossier standard, import depuis un plugin tiers, ou besoin d'un contrôle explicite sur le moment de load.
- Le nom du fichier = le nom du controller Stimulus.
alert_controller.js → data-controller="alert". my_widget_controller.js → data-controller="my-widget" (snake → kebab). Tout écart casse le binding silencieusement.
- Twig Hook pour injecter, pas d'override vendor. Le controller doit se brancher via un template hookable (
sylius_twig_hooks.hooks.<hook_cible>). Éditer un template natif pour ajouter un data-controller = dette qui saute au prochain update Sylius. Si la zone n'a pas de hook, regarder /sylius:template pour en ajouter un ou faire un override ciblé.
controllers.json pour gouverner les natifs. Désactiver (enabled: false), forcer lazy / eager, ou remplacer un controller Sylius core se fait exclusivement via assets/<admin|shop>/controllers.json, pas en monkey-patchant le JS du vendor.
- Rebuild systématique. Toute modif JS/JSON côté assets impose
yarn build (one-shot) ou yarn watch (dev continu). Sans ça, public/build/ reste sur l'ancien bundle — le navigateur ne voit rien.
cache:clear après rebuild. Symfony met en cache le manifest d'assets ; sans purge, la page peut pointer sur l'ancien hash. Combo : yarn build → bin/console cache:clear → hard refresh navigateur (Cmd+Shift+R).
- Pas de logique métier dans le controller. Stimulus = bindings DOM + événements. Un calcul de prix, une règle promo, un flux de checkout → côté serveur (controller Symfony + Twig Hook), pas dans
connect(). Stimulus orchestre l'UI, il ne remplace pas le backend.
- Un controller = une responsabilité. Plutôt que
mega_product_controller.js qui gère quantité + wishlist + modal taille, découper en quantity_controller.js, wishlist_controller.js, size_modal_controller.js et les composer sur le même DOM (data-controller="quantity wishlist").
Déroulement
1 — Cadrer le besoin
Demander (ou confirmer) :
- Contexte :
admin (Tabler) ou shop (Bootstrap) ?
- Page cible : fiche produit, checkout, dashboard admin, grid de commandes, thank-you page, etc.
- Type d'action :
- Ajouter un nouveau comportement (quantité +/−, sticky add-to-cart, confirm dialog, shortcut clavier, etc.)
- Désactiver un controller natif Sylius (ex.
taxon-tree)
- Remplacer un controller natif par le tien (même nom, priorité plus forte)
- Charger un controller existant différemment (
lazy → eager ou l'inverse)
- Source de données : statique (hardcodé), issue du DOM (
data-* attributes), issue du contexte parent Twig (variables passées au template du hookable), ou issue d'un fetch runtime (API Sylius, endpoint custom). Les trois premières sont gratuites côté Stimulus ; la 4e impose un appel HTTP dans le controller (fetch, Axios…).
2 — Identifier l'élément cible et le hook
Pour brancher un controller sur une zone existante, il faut d'abord identifier le hook Twig où injecter le template qui porte le data-controller.
a. Inspecteur navigateur — sur la page visée, Cmd+Shift+C (macOS) / Ctrl+Shift+C, survoler la zone. Les commentaires HTML rendus par Twig Hooks encadrent chaque zone :
...
b. Symfony Profiler — onglet Hooks pour voir l'arbre complet des hooks rendus, avec les priorités.
c. Config vendor (fallback) :
ls vendor/sylius/sylius/src/Sylius/Bundle/ShopBundle/Resources/config/app/twig_hooks/
ls vendor/sylius/sylius/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/
Si la zone est déjà dans un hook → parfait, on va y greffer le template du controller. Si elle n'est pas hookable → voir /sylius:template §5 pour ouvrir un nouveau hook ou §7 pour override ciblé.
3 — Auto-discovery (cas standard)
3.1 — Créer le controller Stimulus
Déposer le fichier sous assets/<admin|shop>/controllers/ — le nom du fichier détermine le nom du controller.
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect() {
alert(this.element.dataset.message || 'Test Alert!');
}
}
Le controller s'enregistre automatiquement sous le nom alert.
Patterns Stimulus courants à connaître pour structurer proprement :
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['input', 'output'];
static values = {
min: { type: Number, default: 1 },
max: Number,
url: String,
};
static classes = ['active', 'loading'];
connect() {
}
disconnect() {
}
increment(event) {
event.preventDefault();
const current = parseInt(this.inputTarget.value, 10);
if (current < this.maxValue) {
this.inputTarget.value = current + 1;
this.outputTarget.classList.add(this.activeClass);
}
}
}
3.2 — Créer le template Twig
Le template porte le data-controller via le helper stimulus_controller(). Ne jamais écrire data-controller="alert" en dur — le helper gère l'ordre et la coexistence de plusieurs controllers.
{# templates/admin/order/show/alert.html.twig #}
<div {{ stimulus_controller('alert', { message: 'Hello from Sylius!' }) }}></div>
Le deuxième argument du helper devient des data-alert-*-value — côté JS, ces valeurs sont dispo via this.messageValue (si static values = { message: String }) ou this.element.dataset.message sinon.
Pour combiner avec des targets et des actions :
<div {{ stimulus_controller('quantity', { min: 1, max: product.stock }) }}>
<button type="button" data-action="click->quantity#decrement">−</button>
<input type="number" data-quantity-target="input" value="1">
<button type="button" data-action="click->quantity#increment">+</button>
<span data-quantity-target="output"></span>
</div>
3.3 — Brancher le template via Twig Hook
sylius_twig_hooks:
hooks:
'sylius_admin.order.show.content':
alert:
template: 'admin/order/show/alert.html.twig'
priority: 100
Pour le shop, exemple d'un compteur de quantité injecté sur la fiche produit entre prices (300) et catalog_promotions (200) :
sylius_twig_hooks:
hooks:
'sylius_shop.product.show.content.info.summary':
quantity_picker:
template: 'shop/product/show/quantity.html.twig'
priority: 250
3.4 — Rebuild + cache
yarn build
yarn watch
php bin/console cache:clear
Hard refresh navigateur : Cmd+Shift+R / Ctrl+Shift+R.
4 — Désactiver ou gouverner un controller natif
Via controllers.json du contexte ciblé. C'est le seul endroit propre pour toucher aux controllers Sylius core.
4.1 — Désactiver
Exemple : désactiver le taxon-tree de l'admin (cas fréquent pour le remplacer par une version maison).
{
"controllers": {
"@sylius/admin-bundle": {
"taxon-tree": {
"enabled": false,
"fetch": "lazy"
}
}
}
}
Rebuild obligatoire. Après ça, même si un data-controller="taxon-tree" traîne dans le DOM, plus rien ne se connecte.
4.2 — Forcer eager/lazy
{
"controllers": {
"@sylius/shop-bundle": {
"live-cart": {
"enabled": true,
"fetch": "eager"
}
}
}
}
lazy (défaut) : le JS du controller n'est téléchargé que quand un data-controller="..." matching apparaît dans le DOM. Optimise le poids initial.
eager : téléchargé immédiatement à l'init de la page. Utile si le controller attache des listeners globaux (raccourcis clavier, popstate, etc.) sans nécessairement être sur un élément DOM visible dès le premier render.
4.3 — Remplacer par un custom du même nom
Désactiver le natif + déposer un <nom>_controller.js dans assets/<contexte>/controllers/ avec le même nom Stimulus. Le moteur prend le tien.
{
"controllers": {
"@sylius/admin-bundle": {
"taxon-tree": { "enabled": false }
}
}
}
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect() { }
}
5 — Enregistrement manuel (cas avancés)
À réserver aux situations où l'auto-discovery ne suffit pas :
- controller hébergé hors
assets/<contexte>/controllers/ (ex. dans un sous-dossier thématique assets/shop/checkout/confirm_controller.js)
- controller fourni par un plugin interne via un package npm privé
- besoin d'exécuter du code custom à l'enregistrement (wiring supplémentaire, transformation du controller)
5.1 — Controller hors dossier standard
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect() {
console.log('Confirm controller loaded.');
}
onClick(event) {
event.preventDefault();
if (!confirm('Confirmer ?')) return;
event.target.closest('form')?.submit();
}
}
5.2 — Enregistrement dans bootstrap.js
import ConfirmController from './custom/confirm_controller';
app.register('confirm', ConfirmController);
La variable app est exposée par @symfony/stimulus-bundle : elle pointe sur l'application Stimulus root. Pas besoin de l'importer explicitement dans un bootstrap.js fourni par le skeleton Sylius 2.1 — il est pré-câblé.
5.3 — Template + hook
{# templates/shop/order/confirmation.html.twig #}
<button
type="submit"
class="btn btn-primary"
data-action="click->confirm#onClick"
{{ stimulus_controller('confirm') }}>
{{ 'app.order.confirm'|trans }}
</button>
sylius_twig_hooks:
hooks:
'sylius_shop.order.thank_you.content.buttons#customer':
confirmation:
template: 'shop/order/confirmation.html.twig'
'sylius_shop.order.thank_you.content.buttons#guest':
confirmation:
template: 'shop/order/confirmation.html.twig'
Les deux hooks #customer et #guest doivent être câblés si le bouton doit s'afficher pour les deux types d'utilisateurs — la thank-you page Sylius a deux variantes.
6 — Communiquer avec le serveur
Quand le controller doit déclencher un appel HTTP (ex. update de quantité panier sans reload) :
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static values = { url: String };
static targets = ['input', 'feedback'];
async update() {
const response = await fetch(this.urlValue, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ quantity: parseInt(this.inputTarget.value, 10) }),
});
if (!response.ok) {
this.feedbackTarget.textContent = 'Erreur';
return;
}
const data = await response.json();
this.feedbackTarget.textContent = `${data.total}`;
}
}
Côté template :
<div {{ stimulus_controller('cart-item', { url: path('app_cart_item_update', { id: item.id }) }) }}>
<input type="number" data-cart-item-target="input" data-action="change->cart-item#update">
<span data-cart-item-target="feedback"></span>
</div>
Important : les endpoints doivent renvoyer du JSON côté Symfony controller, et la route doit accepter la method appelée (PATCH, POST). Pour de la réactivité serveur plus ambitieuse (rerender partiel, validations live), Symfony UX Live Components est plus adapté que Stimulus pur — mentionner l'option à l'utilisateur.
7 — Coexistence avec autres controllers
Plusieurs controllers sur un même élément :
<div {{ stimulus_controller('quantity') }} {{ stimulus_controller('analytics', { event: 'qty_change' }) }}>
...
</div>
Le helper fusionne correctement les data-controller. À la main, ça donnerait data-controller="quantity analytics" — mais laisser le helper gérer.
Communication inter-controllers : via événements custom DOM (this.dispatch('changed', { detail: ... }) émis par un controller, data-action="quantity:changed->analytics#track" côté DOM) ou via l'application Stimulus (this.application.getControllerForElementAndIdentifier(...)). Premier pattern toujours préféré — plus découplé.
8 — Vérification
yarn build
php bin/console cache:clear
php bin/console debug:twig-hooks | grep <hook_cible>
Dans le navigateur (DevTools ouvert) :
- Onglet Sources / Network : le bundle
<contexte>.js contient bien ton controller (chercher le nom, les commentaires //# sourceMappingURL aident en dev).
- Onglet Elements : l'élément cible a bien
data-controller="<nom>".
- Console : pas d'erreur
Failed to register controller "<nom>" ni Controller "<nom>" is not registered. Un connect() avec console.log permet de confirmer l'attachement.
- Onglet Stimulus (extension browser Stimulus Devtools si installée) : liste les controllers connectés avec leurs valeurs/targets.
9 — Clôture
Afficher :
- Fichiers créés/modifiés :
assets/<contexte>/controllers/<nom>_controller.js (ou un chemin custom + entrée dans bootstrap.js)
templates/<contexte>/.../<bloc>.html.twig (avec stimulus_controller(...))
config/packages/sylius_twig_hooks.yaml (entrée du hookable)
- Éventuellement
assets/<contexte>/controllers.json (pour désactiver/gouverner un natif)
- Commandes :
yarn build (ou yarn watch), php bin/console cache:clear.
- Ce qui reste à faire : traductions des labels affichés par le controller (
translations/messages.<locale>.yaml), tests E2E (Playwright / Panther) si le comportement est critique, accessibilité (gérer les états aria-* si le controller masque/affiche du contenu).
Pièges fréquents
- Version Sylius < 2.1 : le
stimulus_controller(), controllers.json et l'auto-discovery n'existent pas. Vérifier sylius/sylius ≥ 2.1 avant toute chose — sinon c'est l'ancien pipeline gulp/jQuery, totalement différent.
- Nom de fichier ≠ nom Stimulus utilisé :
myAlert_controller.js → le nom exposé est my-alert (camelCase du fichier → kebab du controller), pas myAlert. Tester le nom réel avec l'extension Stimulus Devtools si doute.
data-controller écrit en dur : <div data-controller="alert"> au lieu de {{ stimulus_controller('alert') }}. Ça marche mais casse la composition dès qu'un second controller arrive sur le même élément, et contourne la gestion des valeurs du helper (qui sérialise proprement les types).
- Rebuild oublié : modif JS sans
yarn build → public/build/<contexte>.js garde l'ancien contenu. Symptôme : le controller "n'existe pas", mais le fichier est bien là sur le disque. Lancer yarn watch en dev pour éviter le piège.
cache:clear oublié : manifest Symfony périmé → navigateur tire l'ancien hash. Rebuild + clear toujours en combo.
- Hard refresh oublié :
F5 normal → cache navigateur conserve l'ancien JS/CSS. Cmd+Shift+R / Ctrl+Shift+R ou DevTools → Network → Disable cache en permanence pendant le dev.
- Controller dans
assets/controllers/ au lieu de assets/<contexte>/controllers/ : il sera chargé dans les deux contextes (admin et shop), via le fichier assets/controllers.json partagé. Généralement non voulu — toujours contextualiser sous admin/ ou shop/.
- Monkey-patch du vendor pour désactiver un controller natif : éditer
vendor/sylius/.../controllers.json → écrasé au prochain composer update. Toujours passer par assets/<contexte>/controllers.json applicatif, qui override.
- Mettre de la logique métier dans
connect() : calcul de totaux, règles promo, décision checkout. Ça bypass toutes les règles Sylius côté serveur (state machines, processors, promotion rules). Stimulus orchestre l'UI, le backend reste source de vérité.
- Oublier
disconnect() pour un controller avec listeners globaux / timers : setInterval, addEventListener sur window, WebSocket, IntersectionObserver non-cleaned → fuite mémoire qui explose après quelques navigations Turbo. Toujours symétriser connect() et disconnect().
static values = { url: String } oublié mais this.urlValue utilisé : Stimulus fait remonter undefined sans erreur lisible. Déclarer explicitement chaque value utilisée, avec son type.
- Dépendance sur un data-attribute non-Stimulus :
this.element.dataset.foo lu directement au lieu de this.fooValue après déclaration en static values. Fonctionne, mais perd la conversion de type auto (Number, Boolean, Array, Object) et la réactivité fooValueChanged(current, previous).
- Hook branché au mauvais endroit : template injecté via un hook
sylius_shop.product.show.content global alors que la cible était ...content.info.summary. Résultat : l'élément s'affiche, le controller se connecte, mais pas au bon endroit dans le DOM. Toujours vérifier le hook dans le profiler.
- Oublier la variante
#customer / #guest sur la thank-you page : le bouton n'apparaît que pour les logged-in ou que pour les guests selon le hook choisi. Les deux hooks existent — utiliser les deux si le comportement doit être universel.
Argument optionnel
/sylius:dynamic add admin alert --hook=sylius_admin.order.show.content — crée un controller alert avec son template et son hookable dans l'admin.
/sylius:dynamic add shop quantity --hook=sylius_shop.product.show.content.info.summary --priority=250 — crée un picker de quantité sur la fiche produit shop, entre prices et catalog_promotions.
/sylius:dynamic disable admin taxon-tree — génère l'entrée controllers.json pour désactiver le controller natif taxon-tree (package @sylius/admin-bundle).
/sylius:dynamic eager shop live-cart — force le chargement eager d'un controller natif existant.
/sylius:dynamic replace admin taxon-tree — désactive le natif + scaffold un taxon-tree_controller.js applicatif du même nom.
/sylius:dynamic manual shop checkout/confirm — scaffold un controller hors dossier standard + entrée dans bootstrap.js.
/sylius:dynamic sans argument — demande le contexte (admin/shop), le type d'action (add/disable/eager/replace/manual), le nom du controller et le hook cible, puis guide pas à pas (fichier JS → template Twig → hookable → build → cache).