Mettre en place une stack frontend Symfony sans build
Ce guide s'adresse Ă qui connaĂźt dĂ©jĂ Symfony et souhaite une couche frontend sans Ă©tape de build (pas de Webpack, Vite ou esbuild). L'idĂ©e : servir les assets directement, versionnĂ©s par hash, et n'ajouter du JavaScript que lĂ oĂč c'est nĂ©cessaire.
Les combinaisons de versions sont laissées de cÎté volontairement : épinglez celles qui conviennent à votre projet et vérifiez leur compatibilité entre elles.
Ce que vous obtenez
| Couche | Outil |
|---|---|
| Gestion des assets | Symfony AssetMapper |
| Modules JS | Import Maps (natif navigateur) |
| Composants UI | Bootstrap |
| IcĂŽnes | Bootstrap Icons |
| Navigation SPA-like | Turbo Drive + Turbo Frames |
| Comportements JS | Stimulus |
| Templates | Twig |
| CSS | CSS natif (BEM partiel) + utilitaires Bootstrap |
Aucun bundler : AssetMapper + importmap suffisent. Choix sobre et assumĂ© â la qualitĂ© d'implĂ©mentation prime sur l'outillage.
1. Poser AssetMapper + importmap
AssetMapper remplace Webpack Encore. Les dépendances sont déclarées dans importmap.php et résolues par le navigateur via <script type="importmap">.
Structure type de importmap.php :
// importmap.php (extrait) return [ 'app' => ['path' => './assets/app.js', 'entrypoint' => true], '@hotwired/turbo' => ['version' => '...'], '@hotwired/stimulus' => ['version' => '...'], 'bootstrap' => ['version' => '...'], 'bootstrap/dist/css/bootstrap.min.css' => ['version' => '...', 'type' => 'css'], 'bootstrap-icons/font/bootstrap-icons.min.css' => ['version' => '...', 'type' => 'css'], ];
Ajouter une dĂ©pendance : php bin/console importmap:require nom-du-package â met Ă jour importmap.php automatiquement.
Un seul point d'entrée : assets/app.js. Tous les autres modules sont importés depuis ce fichier ou chargés via Stimulus. Gardez un entrypoint unique pour maßtriser ce qui part au navigateur.
2. Activer Turbo Drive puis Turbo Frames
Turbo Drive (global)
Navigation SPA-like sans code serveur : un clic charge la réponse en AJAX et remplace le <body>, sans rechargement complet. Il suffit de l'importer dans l'entrypoint :
import '@hotwired/turbo'; // démarre Turbo Drive globalement
Turbo Frames (navigation partielle)
Un frame charge et met à jour une portion de page indépendamment. Déclarez une zone à rafraßchir :
<turbo-frame id="ma-zone"> ... </turbo-frame>
Selon la version de Turbo, l'observateur de clic n'intercepte pas toujours les attributs data-turbo-frame. Vous pouvez piloter la navigation explicitement dans l'entrypoint :
const FRAME_NAV_PREFIXES = { 'ma-zone': '/prefixe-url' }; document.addEventListener('click', (event) => { const link = event.target.closest('a[href]'); if (link?.dataset.turboFrame) { const frame = document.getElementById(link.dataset.turboFrame); if (frame && 'TURBO-FRAME' === frame.tagName) { event.preventDefault(); frame.src = link.href; // Synchronise l'URL du navigateur (F5 / partage de lien) if (link.dataset.turboFrame in FRAME_NAV_PREFIXES) { window.history.pushState({}, '', link.href); } } } });
Pensez à écouter popstate (window.history) pour resynchroniser le cadre avec le bouton Précédent.
RafraĂźchissement conditionnel d'un cadre
Pour recharger un cadre périodiquement tant qu'une opération de fond tourne, posez un marqueur dans le DOM et testez-le :
setInterval(() => { if (!document.querySelector('[data-operation-en-cours]')) return; document.getElementById('ma-zone')?.reload(); }, 10000);
Le marqueur est retirĂ© cĂŽtĂ© serveur quand l'opĂ©ration se termine : le polling s'arrĂȘte de lui-mĂȘme.
3. Ajouter des comportements avec Stimulus
Stimulus ajoute du JS ciblé sans framework global. Chaque controller est un module ES chargé à la demande.
Les controllers UX Symfony se déclarent dans assets/controllers.json :
{ "controllers": { "@symfony/ux-...": { "nom": { "enabled": true, "fetch": "eager" } } } }
Chargement paresseux : annotez un controller pour ne le charger que s'il est présent dans le DOM.
/* stimulusFetch: 'lazy' */
Pour des interactions simples et transverses (ex. sĂ©lection groupĂ©e de cases Ă cocher), la dĂ©lĂ©gation d'Ă©vĂ©nements globale (click / change) suffit souvent, sans crĂ©er de controller dĂ©diĂ© â et fonctionne que la liste soit en pleine page ou dans un frame.
Protection CSRF compatible Turbo
Comme Turbo intercepte les soumissions, gérez le token CSRF en écoutant les événements Turbo :
// GĂ©nĂšre et double-soumet un token CSRF (champ formulaire + cookie) // Ăcoute turbo:submit-start / turbo:submit-end export function generateCsrfToken(formElement) { /* ... */ } export function generateCsrfHeaders(formElement) { /* ... */ }
4. Importer Bootstrap en module ES
Importez Bootstrap depuis l'importmap, pas depuis un CDN â vous gardez la main sur la version et le versioning par hash :
import 'bootstrap'; // JS (modal, dropdown, collapseâŠ) import 'bootstrap/dist/css/bootstrap.min.css'; // CSS import 'bootstrap-icons/font/bootstrap-icons.min.css'; // IcĂŽnes (font)
Les icĂŽnes s'utilisent directement dans les templates Twig :
<i class="bi bi-rss"></i>
Si vous exposez une icÎne par configuration cÎté PHP, renvoyez simplement sa classe :
public function icon(): string { return 'bi-rss'; }
5. Organiser le CSS et le charger conditionnellement
Séparez les feuilles par surface :
assets/styles/ âââ app.css â rĂšgles globales (chrome Ă©ditable, skip link, utilitaires) âââ admin.css â styles back-office âââ front.css â styles surface publique
Ne servez au visiteur que ce dont il a besoin, via un dynamic import :
import 'bootstrap/dist/css/bootstrap.min.css'; import './styles/app.css'; if (isLoggedIn()) { import('./styles/admin.css'); // pas chargé pour les visiteurs anonymes } export function isLoggedIn() { return window.app?.user !== null; }
Skip link accessible
Un lien d'Ă©vitement doit ĂȘtre hors Ă©cran par dĂ©faut et visible au focus clavier :
.skip-link { position: absolute; top: -1px; transform: translate(-50%, -200%); /* hors-écran par défaut */ transition: all 0.1s; &:focus { transform: translate(-50%, 0%); } /* visible au focus clavier */ }
Neutraliser le style d'un contenu Ă©ditĂ© (Markdown â HTML)
Si des blocs (nav, footer) sont rédigés en Markdown puis rendus en HTML, neutralisez les marges pour qu'un lien Markdown ressemble au chrome natif :
.chrome-edite > :last-child { margin-bottom: 0; } .chrome-edite p { margin: 0; } .chrome-edite ul { list-style: none; display: flex; flex-wrap: wrap; gap: .75rem; } .chrome-edite-nav a { text-decoration: none; }
6. Structurer les templates Twig
Séparez par domaine et par surface, et préfixez les partials par _ :
templates/
âââ admin/ â back-office (nav, tableaux de bord, sections)
âââ components/ â partials rĂ©utilisables (_pagination.html.twig, âŠ)
âââ <domaine>/ â index.html.twig, show.html.twig
âââ bundles/TwigBundle/Exception/
âââ error.html.twig
âââ error403.html.twig
âââ error404.html.twig
Convention : un fichier partial commence par _ (ex. _list.html.twig).
Héritage de layout (multi-surfaces)
Si plusieurs surfaces partagent un moteur mais un habillage diffĂ©rent, faites pointer chaque surface vers son layout_template et faites Ă©tendre ce layout par ses pages â chaque surface n'accĂšde qu'Ă son propre chrome Ă©ditorial.
7. (Option) Web App Manifest
Pour proposer « Ajouter à l'écran d'accueil » sans passer PWA complÚte, un simple manifest suffit :
// site.webmanifest â mĂ©tadonnĂ©es + icĂŽnes (192Ă192, 512Ă512, apple-touch 180Ă180)
Sans Service Worker, pas de mode hors-ligne : c'est un choix assumé si vous ne visez pas l'usage offline.
Points de vigilance
- Un seul entrypoint JS : tout ce qui n'y est pas rattachĂ© ne part pas au navigateur â c'est votre garde-fou de poids.
- Compatibilité des versions : Turbo, Stimulus et Bootstrap évoluent ; testez la navigation par frames et les soumissions CSRF aprÚs chaque montée de version.
- CSS conditionnel : vérifiez qu'aucun style back-office ne fuit vers les visiteurs anonymes.
- Accessibilité : le skip link et l'ordre de focus se testent au clavier, pas seulement à la souris.