Módulo 04: Assets y frontend
Objetivos del módulo
- Entender cómo gestiona Symfony el CSS, el JavaScript y las imágenes con AssetMapper
- Conocer la carpeta
assets/, el ficheroapp.jsy elimportmap.php - Instalar paquetes de JavaScript (Bootstrap y otros) con
importmap:requiresin Node.js - Usar las funciones
asset()eimportmap()en Twig - Comprender el versionado de ficheros y compilar los assets para producción
- Integrar Bootstrap 5 en el blog y saber cuándo usar Sass o Tailwind
- Crear controladores de Stimulus: targets, values y actions
- Conocer Turbo: Turbo Drive, Turbo Frames y Turbo Streams
- Saber cuándo tiene sentido usar Webpack Encore
- Montar el layout completo del blog con Bootstrap y un controlador Stimulus
1. El problema de los assets
📘 Concepto: Los assets son los ficheros estáticos que el navegador descarga además del HTML: hojas de estilo CSS, ficheros JavaScript, imágenes, fuentes, iconos…
Gestionarlos bien no es trivial:
| Problema | Ejemplo |
|---|---|
| Caché del navegador | Cambias app.css pero los usuarios siguen viendo la versión antigua |
| Dependencias | Bootstrap necesita Popper; tu código necesita Bootstrap |
| Organización | Dividir el JavaScript en módulos sin tener 20 etiquetas <script> |
| Rendimiento | Cargar los ficheros en paralelo y comprimidos |
Durante años, la solución en el mundo PHP fue usar herramientas de Node.js (Webpack, Vite…) que empaquetan todo en un único fichero. Funcionan muy bien, pero obligan a instalar Node, a tener un package.json con cientos de dependencias y a ejecutar un proceso de build.
La alternativa moderna: AssetMapper
Hoy los navegadores entienden de forma nativa:
- Módulos ES (
import/export) directamente en el navegador. - Importmaps: un JSON que dice al navegador dónde está cada módulo (
"bootstrap" → "/assets/vendor/bootstrap/bootstrap.index-f093544d.js"). - HTTP/2: descargar muchos ficheros pequeños en paralelo ya no penaliza.
📘 Concepto: AssetMapper es el componente de Symfony que aprovecha estas capacidades. Es la opción por defecto en los proyectos
--webapp: no necesita Node.js ni ningún paso de compilación en desarrollo. Tú escribes JavaScript moderno y CSS normal enassets/, y Symfony los sirve con versionado automático.
flowchart LR
subgraph DEV["Tu proyecto"]
A["assets/app.js"]
CSS["assets/styles/app.css"]
IMG["assets/images/logo.svg"]
IM["importmap.php"]
V["assets/vendor/ (paquetes descargados)"]
end
AM["AssetMapper"]
A --> AM
CSS --> AM
IMG --> AM
IM --> AM
V --> AM
AM -->|"dev: sirve al vuelo"| B["Navegador<br/>importmap + módulos ES"]
AM -->|"prod: asset-map:compile"| P["public/assets/<br/>app-4e986c1a.js ..."]
P --> B
2. La estructura de AssetMapper
Al crear el proyecto con --webapp, la recipe de AssetMapper genera:
blog/
├── assets/
│ ├── app.js ← punto de entrada de JavaScript
│ ├── stimulus_bootstrap.js ← arranque de Stimulus
│ ├── controllers.json ← controladores Stimulus de paquetes UX
│ ├── controllers/ ← tus controladores Stimulus
│ │ ├── hello_controller.js
│ │ └── csrf_protection_controller.js
│ ├── styles/
│ │ └── app.css ← tu CSS
│ └── vendor/ ← paquetes de terceros (no se versiona)
├── config/packages/asset_mapper.yaml
└── importmap.php
config/packages/asset_mapper.yaml
framework:
asset_mapper:
# Carpetas que AssetMapper pone a disposición del navegador
paths:
- assets/
missing_import_mode: strict
when@prod:
framework:
asset_mapper:
missing_import_mode: warn
assets/app.js
// assets/app.js
import './stimulus_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';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
💡 Consejo: Fíjate en que el CSS se importa desde JavaScript. AssetMapper detecta esos
importde ficheros.cssy los convierte en etiquetas<link rel="stylesheet">en el HTML, en el orden en que los importas.
importmap.php
<?php
// importmap.php
return [
'app' => [
'path' => './assets/app.js',
'entrypoint' => true,
],
'@hotwired/stimulus' => [
'version' => '3.2.2',
],
'@symfony/stimulus-bundle' => [
'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js',
],
'@hotwired/turbo' => [
'version' => '7.3.0',
],
];
| Clave | Significado |
|---|---|
'app' | Nombre del módulo (lo que pones en import ... from 'app' o en importmap('app')) |
path | Fichero local dentro de los assets |
entrypoint | true si es un punto de entrada que se carga desde Twig |
version | Paquete de terceros descargado desde un CDN (jsDelivr) a assets/vendor/ |
⚠️ Importante: No edites
importmap.phpa mano para añadir paquetes: usa los comandosimportmap:*. Las versiones concretas que veas en tu fichero pueden diferir de las de este ejemplo.
En base.html.twig
La recipe añade esta línea al bloque javascripts:
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %}
Que genera algo así:
<link rel="stylesheet" href="/assets/styles/app-1a2b3c4d.css">
<script type="importmap">
{
"imports": {
"app": "/assets/app-4e986c1a.js",
"@hotwired/stimulus": "/assets/vendor/@hotwired/stimulus/stimulus.index-b5b1d00e.js"
}
}
</script>
<link rel="modulepreload" href="/assets/app-4e986c1a.js">
<script type="module">import 'app';</script>
3. Instalar paquetes de JavaScript: importmap:require
En lugar de npm install, usas la consola de Symfony:
php bin/console importmap:require bootstrap
[OK] 2 new items (bootstrap, @popperjs/core) added to the importmap.php!
El comando:
- Busca el paquete en el registro de npm (a través del CDN jsDelivr).
- Descarga el fichero a
assets/vendor/bootstrap/. - Detecta sus dependencias (
@popperjs/core) y también las descarga. - Añade las entradas a
importmap.php.
Ya puedes importarlo en tu JavaScript:
// assets/app.js
import 'bootstrap'; // activa los componentes JS de Bootstrap
import { Tooltip } from 'bootstrap'; // o importa solo lo que necesites
Comandos de gestión
| Comando | Para qué |
|---|---|
importmap:require paquete | Añade un paquete (admite versión: bootstrap@5.3.8) |
importmap:require paquete/ruta/fichero.css | Añade un fichero concreto de un paquete |
importmap:remove paquete | Elimina un paquete |
importmap:install | Descarga los paquetes de importmap.php que falten en assets/vendor/ |
importmap:outdated | Lista los paquetes con versiones más nuevas |
importmap:update [paquete] | Actualiza paquetes |
importmap:audit | Busca vulnerabilidades de seguridad conocidas |
debug:asset-map | Lista todos los assets y sus rutas lógicas |
📘 Concepto:
assets/vendor/funciona comovendor/de Composer: no se sube a Git. Cuando un compañero clone el proyecto, al hacercomposer installse ejecuta automáticamenteimportmap:install(la recipe lo añade a los scripts de Composer).
Otros paquetes útiles
php bin/console importmap:require bootstrap@5.3.8
php bin/console importmap:require highlight.js/lib/core highlight.js/lib/languages/php
php bin/console importmap:require canvas-confetti
// assets/app.js — resaltar el código PHP en los artículos del blog
import hljs from 'highlight.js/lib/core';
import php from 'highlight.js/lib/languages/php';
hljs.registerLanguage('php', php);
document.addEventListener('turbo:load', () => hljs.highlightAll());
⚠️ Importante: En los
importrelativos de tus propios ficheros, el navegador exige la extensión:import Slugger from './lib/slugger.js'funciona;import Slugger from './lib/slugger'no.
4. CSS y Bootstrap con AssetMapper
Instalar el CSS de Bootstrap
El JavaScript y el CSS de Bootstrap son ficheros distintos. Para el CSS:
php bin/console importmap:require bootstrap/dist/css/bootstrap.min.css
E impórtalo en app.js antes de tu propio CSS (así tus estilos pueden sobrescribir los de Bootstrap):
// assets/app.js
import './stimulus_bootstrap.js';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap';
import './styles/app.css';
Tu CSS
/* assets/styles/app.css */
:root {
--blog-primary: #3f51b5;
}
.navbar-brand {
font-weight: 700;
letter-spacing: .5px;
}
.article-card:hover {
transform: translateY(-2px);
transition: transform .2s;
}
.hero {
/* Las rutas relativas en url() también se versionan */
background-image: url('../images/hero.jpg');
background-size: cover;
}
Imágenes y la función asset()
Coloca las imágenes dentro de assets/ (por ejemplo, assets/images/logo.svg) y referéncialas con su ruta lógica:
<img src="{{ asset('images/logo.svg') }}" alt="Blog de Symfony" height="32">
{# → <img src="/assets/images/logo-3c16d92m.svg" ...> #}
📘 Concepto: La ruta lógica es la ruta relativa a
assets/. AssetMapper añade al nombre un hash del contenido (logo-3c16d92m.svg). Si cambias la imagen, el hash cambia, la URL cambia y el navegador descarga la nueva versión. Si no cambia, puede cachearla para siempre. Esto se llama versionado o cache busting.
Iconos
Para los iconos, lo más sencillo es cargar Bootstrap Icons desde su CDN oficial en base.html.twig:
{% block stylesheets %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
{% endblock %}
<i class="bi bi-journal-text"></i> Artículos
💡 Consejo: Otra opción muy integrada en Symfony es UX Icons (
composer require symfony/ux-icons), que incrusta iconos SVG con<twig:ux:icon name="bi:journal-text" />, sin cargar fuentes completas.
5. Producción: versionado y asset-map:compile
En desarrollo, AssetMapper sirve cada fichero “al vuelo” a través de Symfony. En producción sería lento, así que antes de desplegar se compilan:
php bin/console asset-map:compile
[OK] Compiled 24 assets
Manifest written to public/assets/manifest.json
Esto copia todos los assets, ya versionados, a public/assets/, para que el servidor web (Nginx, Apache, Caddy…) los sirva directamente sin pasar por PHP.
| Entorno | Cómo se sirven los assets |
|---|---|
dev | Dinámicamente por Symfony; cambias un fichero y recargas |
prod | Desde public/assets/, generado con asset-map:compile en el despliegue |
⚠️ Importante: No ejecutes
asset-map:compileen tu entorno de desarrollo. Si lo haces, Symfony servirá los ficheros compilados depublic/assets/y tus cambios enassets/dejarán de verse. Si te pasa, borra la carpetapublic/assets/.
💡 Consejo: Como los ficheros llevan el hash en el nombre, configura el servidor web para enviarlos con una caché muy larga (
Cache-Control: max-age=31536000, immutable). Lo verás en el módulo 15 (Despliegue).
6. Sass y Tailwind
AssetMapper sirve CSS normal. Si quieres usar un preprocesador o Tailwind, hay bundles oficiales de SymfonyCasts que descargan el ejecutable necesario (sin Node.js) y compilan el CSS:
| Herramienta | Bundle | Comandos principales |
|---|---|---|
| Sass | symfonycasts/sass-bundle | sass:build, sass:build --watch |
| Tailwind CSS | symfonycasts/tailwind-bundle | tailwind:init, tailwind:build --watch |
| TypeScript | sensiolabs/typescript-bundle | typescript:build --watch |
Ejemplo con Sass:
composer require symfonycasts/sass-bundle
{# base.html.twig: apunta al .scss; el bundle lo sustituye por el CSS compilado #}
<link rel="stylesheet" href="{{ asset('styles/app.scss') }}">
php bin/console sass:build --watch
Para no tener que acordarte de lanzar el --watch, Symfony CLI puede arrancarlo junto al servidor con un fichero .symfony.local.yaml:
# .symfony.local.yaml
workers:
sass:
cmd: ['symfony', 'console', 'sass:build', '--watch']
💡 Consejo: En el blog usaremos Bootstrap con CSS normal, que no necesita compilación. Elige Sass si quieres personalizar las variables de Bootstrap (colores, espaciados) o Tailwind si prefieres un enfoque utility-first.
7. Symfony UX y Stimulus
📘 Concepto: Symfony UX es la iniciativa de Symfony para añadir interactividad a las aplicaciones sin construir una SPA. Se apoya en dos librerías del proyecto Hotwire: Stimulus (pequeños controladores de JavaScript unidos al HTML) y Turbo (navegación rápida sin recargar la página).
¿Qué es Stimulus?
Stimulus no genera HTML: lo enriquece. Tú escribes el HTML en Twig como siempre y le añades atributos data-* que conectan elementos con controladores JavaScript.
| Concepto | Atributo HTML | En el controlador JS |
|---|---|---|
| Controller | data-controller="char-counter" | La clase en char_counter_controller.js |
| Target | data-char-counter-target="output" | this.outputTarget |
| Action | data-action="input->char-counter#update" | El método update() |
| Value | data-char-counter-max-value="280" | this.maxValue |
💡 Consejo: Una analogía: el HTML es una casa ya construida y Stimulus es el electricista que conecta interruptores (actions) con bombillas (targets). No construye la casa, solo la “cablea”.
Cómo se cargan los controladores
assets/stimulus_bootstrap.js arranca Stimulus:
// assets/stimulus_bootstrap.js
import { startStimulusApp } from '@symfony/stimulus-bundle';
const app = startStimulusApp();
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);
Cualquier fichero xxx_controller.js en assets/controllers/ se registra automáticamente. El nombre del fichero determina el identificador: char_counter_controller.js → char-counter.
Ejemplo 1: contador de caracteres
Para el formulario de comentarios del blog, queremos mostrar cuántos caracteres quedan.
php bin/console make:stimulus-controller char_counter
// assets/controllers/char_counter_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
// Elementos del HTML a los que queremos acceder
static targets = ['input', 'output'];
// Valores configurables desde el HTML, con tipo y valor por defecto
static values = {
max: { type: Number, default: 280 },
};
// Se ejecuta cuando el elemento con data-controller aparece en la página
connect() {
this.update();
}
// Acción: se llama en cada pulsación
update() {
const remaining = this.maxValue - this.inputTarget.value.length;
this.outputTarget.textContent = `${remaining} caracteres restantes`;
this.outputTarget.classList.toggle('text-danger', remaining < 0);
}
}
El HTML, escrito a mano:
<div data-controller="char-counter" data-char-counter-max-value="500">
<label for="comment" class="form-label">Tu comentario</label>
<textarea id="comment" class="form-control" rows="4"
data-char-counter-target="input"
data-action="input->char-counter#update"></textarea>
<small data-char-counter-target="output" class="form-text"></small>
</div>
O con las funciones Twig de StimulusBundle, más legibles:
<div {{ stimulus_controller('char-counter', {max: 500}) }}>
<label for="comment" class="form-label">Tu comentario</label>
<textarea id="comment" class="form-control" rows="4"
{{ stimulus_target('char-counter', 'input') }}
{{ stimulus_action('char-counter', 'update', 'input') }}></textarea>
<small {{ stimulus_target('char-counter', 'output') }} class="form-text"></small>
</div>
La sintaxis de las acciones
data-action="evento->controlador#método"
| Ejemplo | Significado |
|---|---|
click->theme#toggle | Al hacer clic, llama a toggle() del controlador theme |
input->char-counter#update | En cada cambio de un campo de texto |
submit->form#validate | Al enviar un formulario |
keydown.esc->modal#close | Al pulsar la tecla Escape |
theme#toggle | Evento por defecto del elemento (clic en botones, input en campos, submit en formularios) |
Ciclo de vida de un controlador
| Método | Cuándo se ejecuta |
|---|---|
initialize() | Una vez, cuando se crea el controlador |
connect() | Cada vez que el elemento entra en el DOM |
disconnect() | Cada vez que el elemento sale del DOM |
[nombre]TargetConnected() | Cuando aparece un target |
[nombre]ValueChanged() | Cuando cambia un value |
📘 Concepto: Como Stimulus reacciona a los elementos que aparecen en el DOM, funciona automáticamente con contenido cargado por AJAX o por Turbo. No necesitas
DOMContentLoadedni volver a “inicializar” nada.
Ejemplo 2: alternar tema claro/oscuro
Bootstrap 5.3 admite modo oscuro con el atributo data-bs-theme. Un controlador para alternarlo y recordarlo:
// assets/controllers/theme_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['icon'];
connect() {
this.apply(localStorage.getItem('blog-theme') ?? 'light');
}
toggle() {
const current = document.documentElement.getAttribute('data-bs-theme');
this.apply(current === 'dark' ? 'light' : 'dark');
}
apply(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
localStorage.setItem('blog-theme', theme);
if (this.hasIconTarget) {
this.iconTarget.className = theme === 'dark' ? 'bi bi-sun' : 'bi bi-moon-stars';
}
}
}
<button type="button" class="btn btn-outline-light btn-sm"
{{ stimulus_controller('theme') }}
{{ stimulus_action('theme', 'toggle') }}
aria-label="Cambiar tema">
<i {{ stimulus_target('theme', 'icon') }} class="bi bi-moon-stars"></i>
</button>
💡 Consejo: Para controladores que solo se usan en algunas páginas, añade el comentario
/* stimulusFetch: 'lazy' */en la primera línea del fichero: el JavaScript solo se descargará cuando aparezca en la página un elemento que lo use.
8. Turbo
📘 Concepto: Turbo hace que tu aplicación clásica (HTML generado en el servidor) se sienta como una SPA: navegación sin recargas completas, actualización parcial de la página y actualizaciones en tiempo real. Con
--webappya está instalado (symfony/ux-turbo).
Turbo Drive
Activado por defecto. Intercepta los clics en enlaces y los envíos de formularios, pide la nueva página por AJAX y reemplaza solo el <body> (y fusiona el <head>). El CSS y el JavaScript no se vuelven a descargar ni a ejecutar.
Resultado: la navegación entre las páginas del blog es mucho más rápida sin que escribas nada.
⚠️ Importante: Como la página no se recarga, el evento
DOMContentLoadedsolo se dispara una vez. Si tienes código que debe ejecutarse en cada “página”, usa un controlador Stimulus (recomendado) o el eventoturbo:load.
Para desactivar Turbo en un enlace o formulario concreto (por ejemplo, una descarga):
<a href="{{ path('article_download', {slug: article.slug}) }}" data-turbo="false">Descargar PDF</a>
💡 Consejo: Con Turbo, cuando un formulario tiene errores el servidor debe responder con un código 422. El método
render()deAbstractControllerya lo hace automáticamente si le pasas un formulario enviado y no válido (módulo 07).
Turbo Frames
Un <turbo-frame> es una zona de la página que se actualiza de forma independiente. Los enlaces y formularios dentro del frame solo reemplazan ese frame.
{# templates/article/show.html.twig #}
<turbo-frame id="comments">
{% for comment in comments %}
<p>{{ comment }}</p>
{% endfor %}
<a href="{{ path('article_comments', {slug: slug, page: 2}) }}">Ver más comentarios</a>
</turbo-frame>
Al hacer clic en “Ver más comentarios”, Turbo pide esa URL, busca en la respuesta el <turbo-frame id="comments"> y solo sustituye ese trozo. El resto de la página no cambia.
{# templates/article/comments.html.twig — respuesta de article_comments #}
<turbo-frame id="comments">
{% for comment in comments %}
<p>{{ comment }}</p>
{% endfor %}
</turbo-frame>
Un uso muy habitual es la carga diferida (lazy) de una parte lenta:
<turbo-frame id="related" src="{{ path('article_related', {slug: slug}) }}" loading="lazy">
Cargando artículos relacionados...
</turbo-frame>
Turbo Streams
Los Turbo Streams permiten que el servidor envíe instrucciones del tipo “añade este HTML al final de #comments” o “elimina #comment-42”.
| Acción | Efecto |
|---|---|
append / prepend | Añade contenido al final / al principio del objetivo |
replace / update | Reemplaza el elemento / solo su contenido |
remove | Elimina el elemento |
before / after | Inserta antes / después del elemento |
{# templates/comment/_stream.html.twig #}
<turbo-stream action="append" target="comments">
<template>
<div class="alert alert-light">{{ comment }}</div>
</template>
</turbo-stream>
En el controlador, si el navegador acepta Turbo Streams, devuelves ese formato:
use Symfony\UX\Turbo\TurboBundle;
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->render('comment/_stream.html.twig', ['comment' => $text]);
}
return $this->redirectToRoute('article_show', ['slug' => $slug]);
📘 Concepto: Combinados con Mercure (un protocolo de push en tiempo real), los Turbo Streams pueden enviarse a todos los navegadores conectados: por ejemplo, para que un comentario nuevo aparezca al instante a todos los lectores del artículo. Es un tema avanzado que no cubriremos en detalle.
9. ¿Y Webpack Encore?
Webpack Encore fue durante años la opción por defecto de Symfony. Es una capa sobre Webpack que necesita Node.js y un paso de compilación.
| AssetMapper | Webpack Encore | |
|---|---|---|
| Node.js | No necesario | Necesario |
| Compilación en dev | No | Sí (npm run watch) |
| Configuración | Mínima | webpack.config.js |
| JSX / Vue SFC / TypeScript complejo | Limitado (TypeScript con bundle) | Soporte completo |
| Ideal para | Aplicaciones Symfony con Twig + Stimulus + Turbo | SPAs grandes en React/Vue o proyectos que ya lo usan |
💡 Consejo: Para el 90 % de las aplicaciones Symfony (incluido nuestro blog), AssetMapper es la mejor opción. Usa Encore (
composer require symfony/webpack-encore-bundle) o Vite (mediante un bundle de la comunidad) solo si tu frontend es una aplicación React/Vue compleja.
10. Ejemplo completo: el layout del blog
Juntemos todo: Bootstrap con AssetMapper, un menú responsive, mensajes flash, el cambio de tema y el contador de caracteres.
Paso 1: instalar Bootstrap
php bin/console importmap:require bootstrap
php bin/console importmap:require bootstrap/dist/css/bootstrap.min.css
Paso 2: app.js
// assets/app.js
import './stimulus_bootstrap.js';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap';
import './styles/app.css';
Paso 3: base.html.twig
{# templates/base.html.twig #}
<!DOCTYPE html>
<html lang="es" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ blog_name }}{% endblock %}</title>
<link rel="icon" href="{{ asset('images/favicon.svg') }}">
{% block stylesheets %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
{% endblock %}
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %}
</head>
<body class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-lg bg-primary" data-bs-theme="dark">
<div class="container">
<a class="navbar-brand" href="{{ path('app_home') }}">
<i class="bi bi-journal-richtext"></i> {{ blog_name }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#mainNav" aria-controls="mainNav"
aria-expanded="false" aria-label="Mostrar menú">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{ app.current_route == 'app_home' ? 'active' }}"
href="{{ path('app_home') }}">Inicio</a>
</li>
<li class="nav-item">
<a class="nav-link {{ app.current_route starts with 'article_' ? 'active' }}"
href="{{ path('article_index') }}">Artículos</a>
</li>
<li class="nav-item">
<a class="nav-link {{ app.current_route == 'app_about' ? 'active' }}"
href="{{ path('app_about') }}">Sobre nosotros</a>
</li>
</ul>
<form class="d-flex me-2" role="search" action="{{ path('article_index') }}">
<input class="form-control form-control-sm" type="search" name="q"
placeholder="Buscar..." aria-label="Buscar">
</form>
<button type="button" class="btn btn-outline-light btn-sm"
{{ stimulus_controller('theme') }}
{{ stimulus_action('theme', 'toggle') }}
aria-label="Cambiar tema">
<i {{ stimulus_target('theme', 'icon') }} class="bi bi-moon-stars"></i>
</button>
</div>
</div>
</nav>
<main class="container my-4 flex-grow-1">
{% for type, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ type }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Cerrar"></button>
</div>
{% endfor %}
{% endfor %}
{% block body %}{% endblock %}
</main>
<footer class="bg-body-tertiary py-3 mt-auto">
<div class="container text-center small text-body-secondary">
© {{ 'now'|date('Y') }} {{ blog_name }} · Hecho con Symfony
</div>
</footer>
</body>
</html>
💡 Consejo: El menú desplegable en móvil (
navbar-toggler) y el cierre de alertas (data-bs-dismiss) funcionan gracias alimport 'bootstrap'deapp.js. Si no funcionan, abre la consola del navegador: probablemente falte el paquete en el importmap.
Paso 4: el detalle del artículo con formulario de comentario
{# templates/article/show.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}{{ article.title }} · {{ parent() }}{% endblock %}
{% block body %}
<article class="mb-5">
<h1 class="display-5">{{ article.title }}</h1>
<p class="text-body-secondary">
<i class="bi bi-calendar3"></i> {{ article.publishedAt|date('d/m/Y') }}
</p>
<div class="lead">{{ article.content|nl2br }}</div>
</article>
<section class="card">
<div class="card-body">
<h2 class="h5">Deja un comentario</h2>
<form method="post" action="#">
<div class="mb-3" {{ stimulus_controller('char-counter', {max: 500}) }}>
<textarea name="content" class="form-control" rows="4"
{{ stimulus_target('char-counter', 'input') }}
{{ stimulus_action('char-counter', 'update', 'input') }}></textarea>
<small class="form-text" {{ stimulus_target('char-counter', 'output') }}></small>
</div>
<button class="btn btn-primary"><i class="bi bi-send"></i> Enviar</button>
</form>
</div>
</section>
{% endblock %}
Paso 5: probar
- Visita
/articulos/hola-symfony. - Escribe en el comentario: el contador baja y se pone en rojo al pasar de 500.
- Pulsa el botón de tema: el blog cambia a modo oscuro y lo recuerda al navegar.
- Navega entre páginas: con la pestaña Red de las herramientas del navegador verás que Turbo solo pide el HTML, no el CSS ni el JS.
- Reduce la ventana: el menú se convierte en hamburguesa.
Errores comunes
| Error en la consola del navegador | Causa | Solución |
|---|---|---|
Failed to resolve module specifier "bootstrap" | El paquete no está en el importmap | php bin/console importmap:require bootstrap |
404 en /assets/vendor/... tras clonar el proyecto | Falta assets/vendor/ | php bin/console importmap:install |
Los cambios en app.css no se ven | Hay assets compilados en public/assets/ | Borra public/assets/ |
| El controlador Stimulus no hace nada | Nombre de fichero o identificador incorrecto | char_counter_controller.js ↔ data-controller="char-counter" |
Missing target element "output" | Falta el target en el HTML | Revisa data-char-counter-target="output" |
| Un script solo funciona al recargar la página | Usa DOMContentLoaded con Turbo | Pásalo a un controlador Stimulus |
11. Ejercicios
Ejercicio 1: Bootstrap en el blog
Instala Bootstrap (JS y CSS) con importmap:require, impórtalo en app.js y aplica el layout del paso 3 a tu blog. Comprueba con debug:asset-map --vendor qué ficheros se han descargado y en qué ruta.
Ejercicio 2: Logo versionado
Añade un logo SVG en assets/images/logo.svg y muéstralo en la barra de navegación con asset(). Mira el código fuente de la página para ver el hash en el nombre. Modifica el SVG (por ejemplo, cambia un color) y comprueba que el hash cambia.
Ejercicio 3: Compilación para producción
Ejecuta php bin/console asset-map:compile, inspecciona public/assets/ y el manifest.json. Modifica app.css y comprueba que el cambio no se ve. Después borra public/assets/ y verifica que vuelve a funcionar.
Ejercicio 4: Contador de caracteres
Crea el controlador Stimulus char_counter con un value max y añádelo al formulario de comentarios. Amplíalo para que el botón “Enviar” se desactive (disabled) cuando se supere el máximo (usa un tercer target submit).
Ejercicio 5: Mostrar/ocultar
Crea un controlador toggle con un target content y una acción toggle() que muestre u oculte el contenido (añadiendo/quitando la clase d-none). Úsalo en la página de detalle para un bloque “Mostrar índice del artículo”. Añade un value open (Boolean) para indicar si empieza abierto.
Ejercicio 6: Copiar enlace
Crea un controlador clipboard que copie al portapapeles la URL del artículo (pásala con un value url generado con url() en Twig) usando navigator.clipboard.writeText() y cambie el texto del botón a “¡Copiado!” durante 2 segundos.
Ejercicio 7: Turbo Frame de artículos relacionados
Crea una ruta article_related que devuelva una plantilla con un <turbo-frame id="related"> y dos artículos de ArticleCatalog. En la página de detalle, cárgala de forma diferida con src y loading="lazy". Añade un sleep(1) temporal en el controlador para ver el mensaje “Cargando…”.
Ejercicio 8: Resaltado de código
Instala highlight.js/lib/core y el lenguaje PHP con importmap:require y crea un controlador Stimulus highlight que aplique el resaltado a los bloques <pre><code> del elemento donde se conecte. Añade un artículo de ejemplo con un fragmento de PHP.
Resumen
| Concepto | Comando / sintaxis |
|---|---|
| AssetMapper | Opción por defecto, sin Node.js, basada en importmaps y módulos ES |
| Punto de entrada | assets/app.js (importa CSS y módulos) |
| Importmap | importmap.php + {{ importmap('app') }} en Twig |
| Instalar paquete | php bin/console importmap:require bootstrap |
| CSS de un paquete | importmap:require bootstrap/dist/css/bootstrap.min.css + import en app.js |
| Restaurar paquetes | importmap:install (automático en composer install) |
| Mantenimiento | importmap:outdated, importmap:update, importmap:audit |
| Imagen / fichero | {{ asset('images/logo.svg') }} (ruta lógica dentro de assets/) |
| Depurar | debug:asset-map |
| Producción | asset-map:compile → public/assets/ (nunca en dev) |
| Sass / Tailwind | symfonycasts/sass-bundle, symfonycasts/tailwind-bundle |
| Stimulus: controlador | assets/controllers/char_counter_controller.js ↔ data-controller="char-counter" |
| Stimulus: target | static targets = ['output'] ↔ data-char-counter-target="output" |
| Stimulus: value | static values = { max: Number } ↔ data-char-counter-max-value="280" |
| Stimulus: action | data-action="input->char-counter#update" |
| Helpers Twig | stimulus_controller(), stimulus_target(), stimulus_action() |
| Turbo Drive | Navegación sin recarga; data-turbo="false" para desactivarlo |
| Turbo Frames | <turbo-frame id="..."> actualiza solo una zona |
| Turbo Streams | <turbo-stream action="append" target="..."> |
| Webpack Encore | Solo para frontends complejos con React/Vue |