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 fichero app.js y el importmap.php
  • Instalar paquetes de JavaScript (Bootstrap y otros) con importmap:require sin Node.js
  • Usar las funciones asset() e importmap() 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 en assets/, 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 import de ficheros .css y 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.php a mano para añadir paquetes: usa los comandos importmap:*. 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:

  1. Busca el paquete en el registro de npm (a través del CDN jsDelivr).
  2. Descarga el fichero a assets/vendor/bootstrap/.
  3. Detecta sus dependencias (@popperjs/core) y también las descarga.
  4. 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 como vendor/ de Composer: no se sube a Git. Cuando un compañero clone el proyecto, al hacer composer install se ejecuta automáticamente importmap: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 import relativos 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:compile en tu entorno de desarrollo. Si lo haces, Symfony servirá los ficheros compilados de public/assets/ y tus cambios en assets/ dejarán de verse. Si te pasa, borra la carpeta public/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 DOMContentLoaded ni 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 --webapp ya 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 DOMContentLoaded solo se dispara una vez. Si tienes código que debe ejecutarse en cada “página”, usa un controlador Stimulus (recomendado) o el evento turbo: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() de AbstractController ya 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">
                &copy; {{ '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 al import 'bootstrap' de app.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

  1. Visita /articulos/hola-symfony.
  2. Escribe en el comentario: el contador baja y se pone en rojo al pasar de 500.
  3. Pulsa el botón de tema: el blog cambia a modo oscuro y lo recuerda al navegar.
  4. 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.
  5. 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