/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 :
Au minimum :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)"'@hotwired/stimulus+ uncontrollers.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:templatepour 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(...)dansbootstrap.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 undata-controller= dette qui saute au prochain update Sylius. Si la zone n'a pas de hook, regarder/sylius:templatepour en ajouter un ou faire un override ciblé. controllers.jsonpour gouverner les natifs. Désactiver (enabled: false), forcerlazy/eager, ou remplacer un controller Sylius core se fait exclusivement viaassets/<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) ouyarn watch(dev continu). Sans ça,public/build/reste sur l'ancien bundle — le navigateur ne voit rien. cache:clearaprè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.jsqui gère quantité + wishlist + modal taille, découper enquantity_controller.js,wishlist_controller.js,size_modal_controller.jset les composer sur le même DOM (data-controller="quantity wishlist").
Déroulement
1 — Cadrer le besoin
Demander (ou confirmer) :
- Contexte :
admin(Tabler) oushop(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→eagerou 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 :
<!-- BEGIN HOOK | name: "sylius_shop.product.show.content.info.summary" -->
<!-- BEGIN HOOKABLE | name: "sylius_shop.product.show.content.info.summary.prices" -->
...
<!-- END HOOKABLE -->
<!-- END HOOK -->
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.
// assets/admin/controllers/alert_controller.js
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 {
// Éléments du DOM ciblés
static targets = ['input', 'output'];
// Valeurs passées depuis les data-attributes
static values = {
min: { type: Number, default: 1 },
max: Number,
url: String,
};
// Classes CSS togglables
static classes = ['active', 'loading'];
connect() {
// Appelé à chaque attachement au DOM
}
disconnect() {
// Cleanup (timers, listeners globaux, WebSocket)
}
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
# config/packages/sylius_twig_hooks.yaml
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 # one-shot
# ou
yarn watch # rebuild continu pendant le dev
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).
// assets/admin/controllers.json
{
"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 undata-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.
// assets/admin/controllers.json
{
"controllers": {
"@sylius/admin-bundle": {
"taxon-tree": { "enabled": false }
}
}
}
// assets/admin/controllers/taxon-tree_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect() { /* ma version */ }
}
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ématiqueassets/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
// assets/shop/custom/confirm_controller.js
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
// assets/shop/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>
# config/packages/sylius_twig_hooks.yaml
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) :
// assets/shop/controllers/cart_item_controller.js
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 # pas d'erreur de compilation
php bin/console cache:clear
php bin/console debug:twig-hooks | grep <hook_cible> # le hookable apparaît
Dans le navigateur (DevTools ouvert) :
- Onglet Sources / Network : le bundle
<contexte>.jscontient bien ton controller (chercher le nom, les commentaires//# sourceMappingURLaident en dev). - Onglet Elements : l'élément cible a bien
data-controller="<nom>". - Console : pas d'erreur
Failed to register controller "<nom>"niController "<nom>" is not registered. Unconnect()avecconsole.logpermet 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 dansbootstrap.js)templates/<contexte>/.../<bloc>.html.twig(avecstimulus_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(ouyarn 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 étatsaria-*si le controller masque/affiche du contenu).
Pièges fréquents
- Version Sylius < 2.1 : le
stimulus_controller(),controllers.jsonet l'auto-discovery n'existent pas. Vérifiersylius/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é estmy-alert(camelCase du fichier → kebab du controller), pasmyAlert. 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>.jsgarde l'ancien contenu. Symptôme : le controller "n'existe pas", mais le fichier est bien là sur le disque. Lanceryarn watchen dev pour éviter le piège. cache:clearoublié : manifest Symfony périmé → navigateur tire l'ancien hash. Rebuild + clear toujours en combo.- Hard refresh oublié :
F5normal → cache navigateur conserve l'ancien JS/CSS.Cmd+Shift+R/Ctrl+Shift+Rou DevTools → Network → Disable cache en permanence pendant le dev. - Controller dans
assets/controllers/au lieu deassets/<contexte>/controllers/: il sera chargé dans les deux contextes (admin et shop), via le fichierassets/controllers.jsonpartagé. Généralement non voulu — toujours contextualiser sousadmin/oushop/. - Monkey-patch du vendor pour désactiver un controller natif : éditer
vendor/sylius/.../controllers.json→ écrasé au prochaincomposer update. Toujours passer parassets/<contexte>/controllers.jsonapplicatif, 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,addEventListenersurwindow, WebSocket,IntersectionObservernon-cleaned → fuite mémoire qui explose après quelques navigations Turbo. Toujours symétriserconnect()etdisconnect(). static values = { url: String }oublié maisthis.urlValueutilisé : Stimulus fait remonterundefinedsans erreur lisible. Déclarer explicitement chaque value utilisée, avec son type.- Dépendance sur un data-attribute non-Stimulus :
this.element.dataset.foolu directement au lieu dethis.fooValueaprès déclaration enstatic 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.contentglobal 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/#guestsur 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).