Módulo 03: Plantillas Twig

Objetivos del módulo

  • Entender qué es un motor de plantillas y por qué Symfony usa Twig
  • Dominar la sintaxis básica: {{ }}, {% %} y {# #}
  • Acceder a variables, propiedades, métodos y arrays
  • Usar los filtros y funciones más importantes
  • Controlar el flujo con if y for (incluidos else y la variable loop)
  • Aplicar herencia de plantillas con extends, block y parent()
  • Reutilizar código con include, embed y macros
  • Usar las variables globales de app y definir globales propias
  • Crear extensiones Twig propias con filtros y funciones
  • Conocer los Twig Components y Live Components de Symfony UX
  • Comprender el escape automático y la seguridad en las plantillas
  • Organizar las plantillas del blog de forma profesional

1. ¿Qué es Twig y por qué usarlo?

📘 Concepto: Un motor de plantillas es un lenguaje pensado exclusivamente para generar HTML (u otro texto) a partir de datos. Twig es el motor de plantillas de Symfony, creado también por Fabien Potencier. Las plantillas Twig se compilan a clases PHP optimizadas y se cachean, así que son muy rápidas.

¿Por qué no usar PHP directamente?

PHP empezó siendo un lenguaje de plantillas, pero mezclar PHP y HTML tiene problemas:

<!-- Plantilla en PHP puro -->
<ul>
<?php foreach ($articles as $article): ?>
    <li>
        <a href="/articulos/<?= htmlspecialchars($article->getSlug()) ?>">
            <?= htmlspecialchars(strtoupper($article->getTitle())) ?>
        </a>
    </li>
<?php endforeach; ?>
<?php if (count($articles) === 0): ?>
    <li>No hay artículos</li>
<?php endif; ?>
</ul>

La misma plantilla en Twig:

<ul>
    {% for article in articles %}
        <li>
            <a href="{{ path('article_show', {slug: article.slug}) }}">
                {{ article.title|upper }}
            </a>
        </li>
    {% else %}
        <li>No hay artículos</li>
    {% endfor %}
</ul>
Ventaja de Twig Explicación
Concisión Menos ruido sintáctico: se lee casi como HTML
Seguridad Escapa automáticamente toda la salida: protege contra XSS
Herencia Un layout base y plantillas hijas que solo rellenan huecos
Separación Impide meter lógica de negocio en la vista (no puedes hacer consultas SQL desde Twig)
Extensible Puedes crear tus propios filtros, funciones y componentes
Mensajes de error claros Indican la plantilla y la línea exacta del error

💡 Consejo: Piensa en la plantilla como en un molde de galletas: el molde (plantilla) define la forma, y la masa (los datos que le pasa el controlador) es lo que cambia en cada galleta.


2. Sintaxis básica

Twig solo tiene tres delimitadores:

Delimitador Uso Ejemplo
{{ ... }} Imprimir el resultado de una expresión {{ article.title }}
{% ... %} Hacer algo: etiquetas de control (if, for, block, set…) {% if user %}...{% endif %}
{# ... #} Comentario (no aparece en el HTML generado) {# TODO: paginación #}
{# templates/article/show.html.twig #}
{% set reading_time = 5 %}

<h1>{{ article.title }}</h1>
<p>Lectura de {{ reading_time }} minutos</p>
<p>{{ 'Publicado: ' ~ article.publishedAt|date('d/m/Y') }}</p>

Operadores

Tipo Operadores
Aritméticos +, -, *, /, // (división entera), %, **
Comparación ==, !=, <, >, <=, >= (para comparación estricta, el test is same as(...))
Cadenas starts with, ends with, matches (expresión regular)
Lógicos and, or, not
Concatenación ~ → {{ 'Hola ' ~ name }}
Contención in, not in → {% if 'admin' in roles %}
Rangos 1..5
Ternario {{ published ? 'Publicado' : 'Borrador' }}
Valor por defecto {{ subtitle ?? 'Sin subtítulo' }}, {{ title ?: 'Sin título' }}
Pruebas (tests) is defined, is null, is empty, is even, is odd, is iterable
{% if article.tags is not empty %}
    Etiquetas: {{ article.tags|join(', ') }}
{% endif %}

{% if comments|length is even %}Número par de comentarios{% endif %}

3. Variables y acceso a datos

El controlador pasa las variables a la plantilla:

return $this->render('article/show.html.twig', [
    'article' => $article,       // objeto
    'tags' => ['php', 'symfony'],// array
    'author' => ['name' => 'Ana', 'email' => 'ana@blog.test'], // array asociativo
]);

El operador punto

En Twig, el punto . sirve para todo: propiedades, métodos y claves de arrays.

{{ article.title }}
{{ author.name }}
{{ tags.0 }}            {# primer elemento #}
{{ tags[1] }}           {# también con corchetes #}
{{ author['email'] }}

📘 Concepto: Cuando escribes {{ article.title }}, Twig prueba en este orden:

  1. Si article es un array, la clave title.
  2. Si es un objeto, la propiedad pública title.
  3. El método title().
  4. El método getTitle().
  5. El método isTitle() y luego hasTitle().
  6. Si nada existe, null (o error si strict_variables está activado).

Por eso, aunque tu entidad Article tenga la propiedad $title privada, {{ article.title }} funciona: Twig llama a getTitle(). Y {{ article.published }} llamará a isPublished().

{# Llamar a un método con argumentos #}
{{ article.excerpt(100) }}

Definir variables en la plantilla

{% set total = comments|length %}
{% set full_name = user.firstName ~ ' ' ~ user.lastName %}

{% set sidebar %}
    <aside>Contenido capturado en una variable</aside>
{% endset %}
{{ sidebar }}

⚠️ Importante: Si accedes a una variable que no existe, en dev Twig no falla por defecto (devuelve null), pero en el entorno test la recipe activa strict_variables: true y sí lanzará error. Usa is defined o ?? cuando una variable sea opcional.


4. Filtros

📘 Concepto: Un filtro transforma un valor. Se aplica con la barra vertical | y se pueden encadenar: {{ article.title|lower|capitalize }}. Algunos admiten argumentos entre paréntesis.

Filtros de texto

{{ 'hola symfony'|upper }}          {# HOLA SYMFONY #}
{{ 'HOLA SYMFONY'|lower }}          {# hola symfony #}
{{ 'hola symfony'|capitalize }}     {# Hola symfony #}
{{ 'hola symfony'|title }}          {# Hola Symfony #}
{{ '  hola  '|trim }}               {# hola #}
{{ 'Hola NOMBRE'|replace({'NOMBRE': 'Ana'}) }} {# Hola Ana #}
{{ article.content|striptags }}     {# quita las etiquetas HTML #}
{{ article.content|nl2br }}         {# convierte saltos de línea en <br> #}

Filtros numéricos y de fecha

{{ 1234.5|number_format(2, ',', '.') }}   {# 1.234,50 #}
{{ -7|abs }}                              {# 7 #}
{{ 3.14159|round(2) }}                    {# 3.14 #}

{{ article.publishedAt|date('d/m/Y H:i') }}   {# 03/10/2026 18:30 #}
{{ 'now'|date('Y') }}                         {# 2026 #}
{{ article.publishedAt|date('d/m/Y', 'Europe/Madrid') }}

Filtros de colecciones

{{ tags|length }}                    {# 2 #}
{{ tags|join(', ') }}                {# php, symfony #}
{{ tags|first }} / {{ tags|last }}
{{ articles|slice(0, 3) }}           {# los tres primeros #}
{{ tags|sort|reverse|join(' · ') }}
{{ articles|column('title')|join(', ') }}

{# Funciones flecha: filter, map, reduce #}
{% for article in articles|filter(a => a.published) %}...{% endfor %}
{{ articles|map(a => a.title|upper)|join(', ') }}
{{ comments|reduce((total, c) => total + c.likes, 0) }}

default

{{ article.subtitle|default('Sin subtítulo') }}

default devuelve el valor alternativo si la variable no existe, es null o está vacía ('', []).

El filtro u (cadenas Unicode)

El filtro u envuelve un texto en un objeto del componente String de Symfony y te da sus métodos. Necesita el paquete twig/string-extra:

composer require twig/string-extra
{{ article.content|u.truncate(120, '…') }}              {# máximo 120 caracteres, con "…" #}
{{ article.title|u.length }}                            {# longitud real en caracteres Unicode #}
{{ 'Hola Mundo Symfony'|u.snake }}                      {# hola_mundo_symfony #}
{{ 'hola_mundo'|u.camel }}                              {# holaMundo #}
{{ article.title|u.wordwrap(40) }}
{{ 'Programación en España'|slug }}                     {# Programacion-en-Espana #}

💡 Consejo: Para fechas y números en el formato del idioma del usuario, instala twig/intl-extra y usa format_datetime, format_date, format_number o format_currency: {{ article.publishedAt|format_date('long', locale='es') }} → “3 de octubre de 2026”.

Tabla de filtros más usados

Filtro Ejemplo Resultado
upper / lower {{ 'Hola'\|upper }} HOLA
capitalize / title {{ 'hola mundo'\|title }} Hola Mundo
date {{ fecha\|date('d/m/Y') }} 03/10/2026
number_format {{ 1234.5\|number_format(2, ',', '.') }} 1.234,50
length {{ tags\|length }} 2
default {{ x\|default('—') }} — si está vacío
join {{ tags\|join(', ') }} php, symfony
slice {{ list\|slice(0, 3) }} Primeros 3 elementos
first / last {{ tags\|first }} php
nl2br {{ texto\|nl2br }} Saltos de línea → <br>
striptags {{ html\|striptags }} Texto sin etiquetas
raw {{ html\|raw }} Sin escapar (¡cuidado!)
escape / e {{ x\|e('js') }} Escapado para JavaScript
u {{ texto\|u.truncate(50) }} Texto truncado
filter / map {{ list\|map(a => a.title) }} Transformación con funciones flecha

5. Funciones

📘 Concepto: Una función genera contenido o datos. Se llama por su nombre con paréntesis: {{ path('app_home') }}.

Función Ejemplo Para qué
path() {{ path('article_show', {slug: a.slug}) }} URL relativa a partir de una ruta
url() {{ url('article_show', {slug: a.slug}) }} URL absoluta
asset() {{ asset('images/logo.png') }} Ruta pública de un asset (con versión)
include() {{ include('article/_card.html.twig') }} Insertar otra plantilla
dump() {{ dump(article) }} Depurar una variable (solo en dev)
range() {% for i in range(1, 5) %} Secuencia de números
date() {{ date('now')\|date('Y') }} Crear un objeto fecha
max() / min() {{ max(1, 5, 3) }} Máximo / mínimo
random() {{ random(['rojo', 'azul']) }} Elemento aleatorio
cycle() {{ cycle(['par', 'impar'], loop.index0) }} Alterna valores
csrf_token() {{ csrf_token('delete' ~ a.id) }} Token CSRF
is_granted() {% if is_granted('ROLE_ADMIN') %} Comprueba permisos (módulo 10)
importmap() {{ importmap('app') }} Carga el JavaScript (módulo 04)
<footer>
    <img src="{{ asset('images/logo.svg') }}" alt="Logo del blog">
    &copy; {{ 'now'|date('Y') }} Blog de Symfony

    {# Paginación sencilla #}
    {% for page in range(1, total_pages) %}
        <a href="{{ path('article_index', {page: page}) }}">{{ page }}</a>
    {% endfor %}
</footer>

💡 Consejo: Ejecuta php bin/console debug:twig para ver todos los filtros, funciones, tests y variables globales disponibles en tu proyecto (incluidos los que añaden los bundles instalados).


6. Estructuras de control

if, elseif, else

{% if article.comments|length > 10 %}
    <span class="badge bg-danger">¡Muy comentado!</span>
{% elseif article.comments is not empty %}
    <span class="badge bg-secondary">{{ article.comments|length }} comentarios</span>
{% else %}
    <span class="badge bg-light text-dark">Sin comentarios</span>
{% endif %}

for con else

{% for article in articles %}
    <h2>{{ article.title }}</h2>
{% else %}
    <p>Todavía no hay artículos publicados.</p>
{% endfor %}

El bloque else se ejecuta si la colección está vacía.

Recorrer claves y valores

{% for slug, article in articles %}
    <a href="{{ path('article_show', {slug: slug}) }}">{{ article.title }}</a>
{% endfor %}

{# Solo las claves #}
{% for key in author|keys %}{{ key }} {% endfor %}

La variable loop

Dentro de un for, Twig te da la variable especial loop:

Variable Significado
loop.index Iteración actual empezando en 1
loop.index0 Iteración actual empezando en 0
loop.revindex Iteraciones que faltan (terminando en 1)
loop.first true en la primera iteración
loop.last true en la última iteración
loop.length Número total de elementos
loop.parent Contexto del bucle padre (bucles anidados)
<ol>
{% for article in articles %}
    <li class="{{ loop.first ? 'destacado' }} {{ cycle(['fila-par', 'fila-impar'], loop.index0) }}">
        {{ loop.index }}/{{ loop.length }}: {{ article.title }}
        {% if not loop.last %}<hr>{% endif %}
    </li>
{% endfor %}
</ol>

⚠️ Importante: En Twig 3 ya no existe la sintaxis {% for a in articles if a.published %}. Usa el filtro filter: {% for a in articles|filter(a => a.published) %}.


7. Herencia de plantillas

📘 Concepto: La herencia es la característica más potente de Twig. Defines un layout base con la estructura común (cabecera, menú, pie) y unos bloques (huecos). Las plantillas hijas extienden el layout y solo rellenan los bloques que necesitan.

Es como un formulario impreso con casillas: la estructura es siempre la misma y cada persona solo rellena las casillas.

flowchart TB
    B["base.html.twig<br/>head, header, footer<br/>bloques: title, stylesheets, body, javascripts"]
    L["layout del blog (opcional)<br/>extends base<br/>añade sidebar"]
    I["article/index.html.twig<br/>extends base<br/>rellena title y body"]
    S["article/show.html.twig<br/>extends base<br/>rellena title y body"]
    A["page/about.html.twig<br/>extends base"]
    B --> I
    B --> S
    B --> A
    B --> L

La plantilla base

{# templates/base.html.twig #}
<!DOCTYPE html>
<html lang="es">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>{% block title %}Blog de Symfony{% endblock %}</title>

        {% block stylesheets %}{% endblock %}

        {% block javascripts %}
            {% block importmap %}{{ importmap('app') }}{% endblock %}
        {% endblock %}
    </head>
    <body>
        {{ include('_partials/_header.html.twig') }}

        <main class="container">
            {{ include('_partials/_flashes.html.twig') }}

            {% block body %}{% endblock %}
        </main>

        {% block footer %}
            <footer>&copy; {{ 'now'|date('Y') }} Blog de Symfony</footer>
        {% endblock %}
    </body>
</html>

Una plantilla hija

{# templates/article/show.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}{{ article.title }} | {{ parent() }}{% endblock %}

{% block body %}
    <article>
        <h1>{{ article.title }}</h1>
        <p>{{ article.content|nl2br }}</p>
    </article>
{% endblock %}

{% block footer %}
    {{ parent() }}
    <p>¿Te ha gustado? ¡Compártelo!</p>
{% endblock %}
Etiqueta / función Qué hace
{% extends 'base.html.twig' %} Indica de qué plantilla hereda. Debe ser la primera etiqueta
{% block nombre %}...{% endblock %} Define un hueco (en el padre) o lo rellena (en el hijo)
{{ parent() }} Imprime el contenido que tenía el bloque en el padre
{{ block('title') }} Imprime el contenido de otro bloque

⚠️ Importante: En una plantilla que usa extends, todo el contenido debe estar dentro de bloques. Cualquier HTML fuera de un bloque provoca un error.

Herencia en tres niveles

{# templates/article/layout.html.twig — layout de la sección de artículos #}
{% extends 'base.html.twig' %}

{% block body %}
    <div class="row">
        <div class="col-md-8">{% block content %}{% endblock %}</div>
        <aside class="col-md-4">
            {% block sidebar %}
                <h3>Categorías</h3>
                <ul><li>Symfony</li><li>PHP</li></ul>
            {% endblock %}
        </aside>
    </div>
{% endblock %}
{# templates/article/index.html.twig #}
{% extends 'article/layout.html.twig' %}

{% block title %}Artículos{% endblock %}

{% block content %}
    <h1>Artículos</h1>
    {# ... #}
{% endblock %}

8. include y embed

include: insertar fragmentos

{# templates/article/_card.html.twig #}
<div class="card mb-3">
    <div class="card-body">
        <h2 class="card-title h5">
            <a href="{{ path('article_show', {slug: article.slug}) }}">{{ article.title }}</a>
        </h2>
        <p class="card-text">{{ article.summary }}</p>
    </div>
</div>
{% for article in articles %}
    {{ include('article/_card.html.twig', {article: article}) }}
{% endfor %}

{# Pasar SOLO las variables indicadas (aisla el fragmento) #}
{{ include('article/_card.html.twig', {article: article}, with_context = false) }}

{# No fallar si la plantilla no existe #}
{{ include('promo/_banner.html.twig', ignore_missing = true) }}

💡 Consejo: Twig tiene también la etiqueta {% include %}, pero la documentación recomienda la función {{ include() }}: es más flexible y coherente con el resto de funciones.

embed: incluir y sobrescribir bloques

embed combina include y extends: insertas una plantilla y además puedes rellenar sus bloques.

{# templates/_partials/_panel.html.twig #}
<div class="card">
    <div class="card-header">{% block header %}Panel{% endblock %}</div>
    <div class="card-body">{% block content %}{% endblock %}</div>
</div>
{% embed '_partials/_panel.html.twig' %}
    {% block header %}Artículos más leídos{% endblock %}
    {% block content %}
        <ol>
            {% for a in popular %}<li>{{ a.title }}</li>{% endfor %}
        </ol>
    {% endblock %}
{% endembed %}
Herramienta Cuándo usarla
extends Una página completa que usa un layout
include() Un fragmento reutilizable sin variaciones de estructura (tarjeta, cabecera)
embed Un fragmento con huecos que cambian en cada uso (panel, modal)
Macros Pequeños trozos de HTML parametrizados (botones, badges)
Twig Components Componentes con lógica PHP propia (sección 12)

9. Macros

📘 Concepto: Una macro es como una función PHP escrita en Twig: recibe argumentos y devuelve HTML.

{# templates/_macros/ui.html.twig #}
{% macro badge(text, type = 'secondary') %}
    <span class="badge text-bg-{{ type }}">{{ text }}</span>
{% endmacro %}

{% macro article_status(article) %}
    {% if article.published %}
        {{ _self.badge('Publicado', 'success') }}
    {% else %}
        {{ _self.badge('Borrador', 'warning') }}
    {% endif %}
{% endmacro %}
{# Importar todas las macros con un alias #}
{% import '_macros/ui.html.twig' as ui %}

{{ ui.badge('Symfony') }}
{{ ui.badge('Nuevo', 'danger') }}
{{ ui.article_status(article) }}

{# O importar solo algunas #}
{% from '_macros/ui.html.twig' import badge %}
{{ badge('PHP 8.5', 'info') }}

⚠️ Importante: Las macros no ven las variables de la plantilla que las llama; solo sus argumentos. Si necesitas algo, pásalo como argumento.


10. Variables globales

La variable app

Symfony inyecta en todas las plantillas la variable app:

Variable Contenido
app.user Usuario autenticado o null
app.request El objeto Request actual
app.session La sesión
app.flashes Mensajes flash (y los borra al leerlos); app.flashes('success') filtra por tipo
app.environment dev, test o prod
app.debug true si el modo debug está activo
app.current_route Nombre de la ruta actual
app.current_route_parameters Parámetros de la ruta actual
app.locale Idioma actual
app.enabled_locales Idiomas habilitados
{# templates/_partials/_header.html.twig #}
<nav>
    <a href="{{ path('app_home') }}"
       class="{{ app.current_route == 'app_home' ? 'active' }}">Inicio</a>
    <a href="{{ path('article_index') }}"
       class="{{ app.current_route starts with 'article_' ? 'active' }}">Artículos</a>

    {% if app.user %}
        Hola, {{ app.user.userIdentifier }}
    {% else %}
        <a href="#">Iniciar sesión</a>
    {% endif %}

    {% if app.environment == 'dev' %}
        <span class="badge text-bg-warning">DEV</span>
    {% endif %}
</nav>
{# templates/_partials/_flashes.html.twig #}
{% 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 %}

Globales propias

Define en twig.yaml valores disponibles en todas las plantillas:

# config/packages/twig.yaml
twig:
    file_name_pattern: '*.twig'
    globals:
        blog_name: 'Blog de Symfony'
        blog_contact: 'hola@blog.test'
        analytics_id: '%env(default::ANALYTICS_ID)%'
        # Un servicio completo (úsalo con moderación)
        article_catalog: '@App\Service\ArticleCatalog'

when@test:
    twig:
        strict_variables: true
<title>{% block title %}{{ blog_name }}{% endblock %}</title>
<a href="mailto:{{ blog_contact }}">Contacto</a>

💡 Consejo: Exponer servicios como globales es cómodo pero los carga en todas las peticiones. Para lógica reutilizable es mejor una extensión Twig (siguiente sección), que se carga de forma perezosa.


11. Extensiones Twig propias

Cuando necesites un filtro o una función que Twig no trae, créalos tú.

Con atributos (forma recomendada)

Desde Twig 3.21 y Symfony 7.3 puedes marcar métodos de cualquier clase con #[AsTwigFilter] y #[AsTwigFunction]. Gracias al autoconfigure, Symfony la registra automáticamente; no hace falta heredar de nada.

<?php
// src/Twig/BlogExtension.php
declare(strict_types=1);

namespace App\Twig;

use App\Service\ArticleCatalog;
use Twig\Attribute\AsTwigFilter;
use Twig\Attribute\AsTwigFunction;

final readonly class BlogExtension
{
    public function __construct(
        private ArticleCatalog $catalog,
    ) {
    }

    /**
     * Tiempo estimado de lectura: {{ article.content|reading_time }}
     */
    #[AsTwigFilter('reading_time')]
    public function readingTime(string $text, int $wordsPerMinute = 200): string
    {
        $words = str_word_count(strip_tags($text));
        $minutes = max(1, (int) ceil($words / $wordsPerMinute));

        return $minutes === 1 ? '1 minuto' : "$minutes minutos";
    }

    /**
     * Número de artículos publicados: {{ article_count() }}
     */
    #[AsTwigFunction('article_count')]
    public function articleCount(): int
    {
        return count($this->catalog->findAll());
    }
}
<p>Lectura: {{ article.content|reading_time }}</p>
<p>Lectura lenta: {{ article.content|reading_time(100) }}</p>
<p>El blog tiene {{ article_count() }} artículos.</p>

📘 Concepto: Las extensiones con atributos son perezosas (lazy): la clase solo se instancia cuando una plantilla usa realmente el filtro o la función. Por eso puedes inyectarle servicios sin penalizar el rendimiento.

Si el filtro devuelve HTML que no quieres que se escape, indícalo con isSafe:

#[AsTwigFilter('highlight', isSafe: ['html'])]
public function highlight(string $text, string $word): string
{
    $escaped = htmlspecialchars($text);          // ¡escapa tú la entrada!
    $word = htmlspecialchars($word);

    return str_ireplace($word, "<mark>$word</mark>", $escaped);
}

Con AbstractExtension (forma clásica)

Seguirás viendo esta forma en muchos proyectos y bundles:

<?php
// src/Twig/LegacyBlogExtension.php
declare(strict_types=1);

namespace App\Twig;

use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;

final class LegacyBlogExtension extends AbstractExtension
{
    public function getFilters(): array
    {
        return [
            new TwigFilter('excerpt', $this->excerpt(...)),
        ];
    }

    public function getFunctions(): array
    {
        return [
            new TwigFunction('blog_year', fn (): string => date('Y')),
        ];
    }

    public function excerpt(string $text, int $length = 150): string
    {
        $text = strip_tags($text);

        return mb_strlen($text) > $length ? mb_substr($text, 0, $length) . '…' : $text;
    }
}

💡 Consejo: Puedes generar el esqueleto de una extensión con php bin/console make:twig-extension. Comprueba después con debug:twig --filter=reading que tu filtro está registrado.


12. Twig Components y Live Components

Symfony UX es una iniciativa que añade componentes de interfaz modernos a Symfony. Dos de sus piezas más útiles son los Twig Components y los Live Components.

Twig Components

📘 Concepto: Un Twig Component es una clase PHP unida a una plantilla. Es como una macro o un include, pero con su propia lógica PHP, propiedades tipadas y la posibilidad de recibir servicios.

composer require symfony/ux-twig-component

La clase:

<?php
// src/Twig/Components/ArticleCard.php
declare(strict_types=1);

namespace App\Twig\Components;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
final class ArticleCard
{
    public string $slug;
    public string $title;
    public string $summary = '';
    public bool $featured = false;

    public function getBadge(): ?string
    {
        return $this->featured ? 'Destacado' : null;
    }
}

La plantilla, en templates/components/ con el mismo nombre que la clase:

{# templates/components/ArticleCard.html.twig #}
<div {{ attributes.defaults({class: 'card mb-3'}) }}>
    <div class="card-body">
        {% if this.badge %}
            <span class="badge text-bg-warning">{{ this.badge }}</span>
        {% endif %}
        <h2 class="h5"><a href="{{ path('article_show', {slug: slug}) }}">{{ title }}</a></h2>
        <p>{{ summary }}</p>
        {% block content %}{% endblock %}
    </div>
</div>

Uso, con sintaxis tipo HTML:

{# Valor literal #}
<twig:ArticleCard slug="hola-symfony" title="Hola, Symfony 8" />

{# Valores dinámicos: prefijo ":" #}
{% for slug, article in articles %}
    <twig:ArticleCard
        :slug="slug"
        :title="article.title"
        :summary="article.summary"
        :featured="loop.first"
        class="shadow-sm"
    >
        <a href="{{ path('article_show', {slug: slug}) }}">Leer más →</a>
    </twig:ArticleCard>
{% endfor %}

{# Sintaxis alternativa con función #}
{{ component('ArticleCard', {slug: 'twig-desde-cero', title: 'Twig desde cero'}) }}
Elemento Significado
prop="texto" Pasa una cadena literal
:prop="expresión" Pasa el resultado de una expresión Twig
Contenido entre etiquetas Rellena el bloque content
{{ attributes }} Atributos HTML extra que no son propiedades (como class)
this La instancia del componente (para llamar a sus métodos)

Componentes anónimos

Si no necesitas lógica PHP, basta con la plantilla y la etiqueta {% props %}:

{# templates/components/Alert.html.twig #}
{% props type = 'info', dismissible = false %}

<div {{ attributes.defaults({class: 'alert alert-' ~ type}) }} role="alert">
    {% block content %}{% endblock %}
    {% if dismissible %}
        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
    {% endif %}
</div>
<twig:Alert type="warning" :dismissible="true">
    Este artículo tiene más de un año.
</twig:Alert>

Live Components (introducción)

📘 Concepto: Un Live Component es un Twig Component que se re-renderiza vía AJAX cuando el usuario interactúa con él, sin que escribas JavaScript. Ideal para buscadores en vivo, filtros o contadores.

composer require symfony/ux-live-component
<?php
// src/Twig/Components/ArticleSearch.php
declare(strict_types=1);

namespace App\Twig\Components;

use App\Service\ArticleCatalog;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent]
final class ArticleSearch
{
    use DefaultActionTrait;

    #[LiveProp(writable: true)]
    public string $query = '';

    public function __construct(
        private readonly ArticleCatalog $catalog,
    ) {
    }

    /** @return array<string, array<string, string>> */
    public function getResults(): array
    {
        if ($this->query === '') {
            return [];
        }

        return array_filter(
            $this->catalog->findAll(),
            fn (array $a): bool => mb_stripos($a['title'], $this->query) !== false,
        );
    }
}
{# templates/components/ArticleSearch.html.twig #}
<div {{ attributes }}>
    <input type="search" class="form-control" placeholder="Buscar artículos..."
           data-model="query">

    <ul class="list-group mt-2">
        {% for slug, article in this.results %}
            <li class="list-group-item">
                <a href="{{ path('article_show', {slug: slug}) }}">{{ article.title }}</a>
            </li>
        {% else %}
            {% if query %}<li class="list-group-item">Sin resultados</li>{% endif %}
        {% endfor %}
    </ul>
</div>
<twig:ArticleSearch />

Cada vez que escribes en el campo (data-model="query"), el componente envía el nuevo valor al servidor, se vuelve a renderizar y actualiza solo esa parte de la página.

⚠️ Importante: Los Live Components necesitan Stimulus (que verás en el módulo 04) y el elemento raíz de la plantilla debe llevar {{ attributes }}. Para lanzar métodos del componente desde un botón se usan acciones (#[LiveAction] y data-action="live#action"); aquí solo vemos la idea general.


13. Escape automático y seguridad

📘 Concepto: Por defecto, Twig escapa todo lo que imprimes con {{ }}: convierte <, >, &, " y ' en entidades HTML. Así, si un usuario escribe <script> en un comentario, se mostrará como texto y no se ejecutará. Esto protege contra XSS (Cross-Site Scripting).

{% set comment = '<script>alert("hackeado")</script>' %}

{{ comment }}
{# Resultado en el HTML: &lt;script&gt;alert(&quot;hackeado&quot;)&lt;/script&gt; → se ve como texto #}

{{ comment|raw }}
{# ¡PELIGRO! Se ejecuta el script #}

Estrategias de escape

El contexto importa: no se escapa igual dentro de HTML que dentro de JavaScript o de un atributo.

<p>{{ text }}</p>                                   {# html (por defecto) #}
<a href="/buscar?q={{ q|url_encode }}">            {# parámetro de URL #}
<div data-titulo="{{ title|e('html_attr') }}">      {# atributo HTML #}
<script>
    const titulo = '{{ title|e('js') }}';           {# dentro de JavaScript #}
</script>

Cuándo usar raw

Solo cuando tú controlas el HTML (por ejemplo, contenido de un editor que ya has saneado en el servidor con el componente HtmlSanitizer):

{# El contenido del artículo se ha limpiado al guardarlo #}
<div class="article-body">{{ article.contentHtml|raw }}</div>

⚠️ Importante: Nunca uses |raw con datos introducidos por usuarios sin sanear. Para HTML de usuarios, usa el componente HtmlSanitizer de Symfony (composer require symfony/html-sanitizer), que añade el filtro sanitize_html.

Buenas prácticas de seguridad en plantillas

Práctica Por qué
No desactives el autoescape globalmente Es tu primera línea de defensa contra XSS
Usa e('js') dentro de <script> El escape HTML no basta en JavaScript
Para pasar datos a JS, usa json_encode en un atributo data-* Evita construir JavaScript con concatenaciones
Genera siempre las URLs con path()/url() Codifican bien los parámetros
Incluye csrf_token() en formularios que modifican datos Protección contra CSRF (módulo 07)
{# Pasar datos a JavaScript de forma segura #}
<div id="stats" data-stats="{{ {views: 120, likes: 8}|json_encode|e('html_attr') }}"></div>

14. Organización de las plantillas

Symfony recomienda estas convenciones:

  • Nombres en snake_case: article/show.html.twig, blog_post.html.twig.
  • Una carpeta por controlador o sección: templates/article/, templates/page/.
  • Los fragmentos (para include) empiezan por guion bajo: _card.html.twig.
  • La extensión indica el formato: .html.twig, .json.twig, .txt.twig, .xml.twig.

Estructura de plantillas del blog:

templates/
├── base.html.twig
├── _partials/
│   ├── _header.html.twig
│   ├── _footer.html.twig
│   └── _flashes.html.twig
├── _macros/
│   └── ui.html.twig
├── components/
│   ├── Alert.html.twig
│   ├── ArticleCard.html.twig
│   └── ArticleSearch.html.twig
├── article/
│   ├── layout.html.twig
│   ├── index.html.twig
│   ├── show.html.twig
│   └── _card.html.twig
├── page/
│   ├── home.html.twig
│   └── about.html.twig
└── bundles/
    └── TwigBundle/
        └── Exception/
            ├── error404.html.twig
            └── error.html.twig

Herramientas de calidad

php bin/console lint:twig templates/     # detecta errores de sintaxis
php bin/console debug:twig               # lista filtros, funciones y globales

Bootstrap 5 en las plantillas

Twig no impone ningún framework CSS. En el blog usaremos Bootstrap 5, que instalaremos con AssetMapper en el módulo 04. Mientras tanto, puedes probarlo cargándolo por CDN en base.html.twig:

{% block stylesheets %}
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css">
{% endblock %}

💡 Consejo: Symfony incluye temas de formulario para Bootstrap 5 (bootstrap_5_layout.html.twig), que hacen que los formularios se rendericen con las clases de Bootstrap automáticamente. Lo configurarás en el módulo 07 (Formularios).

Errores comunes

Error Causa Solución
Unknown "xxx" filter El filtro no existe o falta su paquete (u, format_date…) Instala twig/string-extra / twig/intl-extra o revisa el nombre con debug:twig
A template that extends another one cannot include content outside Twig blocks HTML fuera de bloques en una plantilla hija Mete todo dentro de {% block %}
Variable "xxx" does not exist No se pasó la variable desde el controlador (con strict_variables) Pásala o usa ?? / default
Neither the property "xxx" nor one of the methods ... exist La propiedad no tiene getter público Añade el getter o corrige el nombre
Se ve &lt;b&gt; en lugar de negrita Autoescape del HTML Correcto si son datos de usuario; usa raw solo con HTML de confianza

15. Ejercicios

Ejercicio 1: Layout del blog

Reescribe templates/base.html.twig para que incluya _partials/_header.html.twig (menú con Inicio, Artículos y Sobre nosotros, marcando como activo el enlace de la ruta actual con app.current_route), _partials/_flashes.html.twig y _partials/_footer.html.twig (con el año actual y la global blog_name).

Ejercicio 2: Listado con loop

En article/index.html.twig, muestra los artículos numerados (“1 de 3”) con loop.index y loop.length, resalta el primero con una clase CSS y muestra “No hay artículos” si la lista está vacía. Añade el total de artículos con length en el título.

Ejercicio 3: Filtros de texto

En la tarjeta de artículo (_card.html.twig), muestra el título en formato título, el resumen truncado a 80 caracteres con u.truncate (terminado en “…”) y la fecha con el formato d/m/Y. Si no hay resumen, muestra “Sin resumen” con default.

Ejercicio 4: Macros de interfaz

Crea _macros/ui.html.twig con tres macros: badge(text, type), button_link(text, url, type = 'primary') y icon_text(icon, text). Úsalas en la página de detalle para mostrar las etiquetas del artículo y un botón “Volver al listado”.

Ejercicio 5: Extensión reading_time y excerpt

Crea App\Twig\BlogExtension con atributos: un filtro reading_time y una función blog_stats() que devuelva un array con el número de artículos y la fecha del último publicado (usa ArticleCatalog). Muestra ambos en el pie de página. Verifícalo con debug:twig.

Ejercicio 6: Componente ArticleCard

Instala symfony/ux-twig-component y convierte _card.html.twig en el componente ArticleCard. Añade una propiedad featured y un método que devuelva la clase CSS del borde (border-warning si es destacado). Usa el componente en la portada y en el listado.

Ejercicio 7: Componente anónimo Alert

Crea un componente anónimo Alert con {% props type = 'info', dismissible = false %} y reescribe _flashes.html.twig para que use <twig:Alert> en lugar del HTML de Bootstrap.

Ejercicio 8: XSS controlado

Crea una ruta de prueba que pase a la plantilla la variable comment con el valor <img src=x onerror=alert('XSS')>. Muéstrala con {{ comment }}, {{ comment|raw }} y {{ comment|striptags }}, y explica en un comentario Twig qué ocurre en cada caso. Después, borra la ruta.


Resumen

Concepto Sintaxis
Imprimir {{ article.title }}
Etiqueta {% if %}, {% for %}, {% set %}, {% block %}
Comentario {# ... #}
Filtro {{ texto\|upper }}, {{ fecha\|date('d/m/Y') }}
Valor por defecto {{ x\|default('—') }}, {{ x ?? '—' }}
Función {{ path('ruta', {param: v}) }}, {{ asset('img.png') }}
Bucle {% for a in articles %}...{% else %}...{% endfor %}
Variable loop loop.index, loop.first, loop.last, loop.length
Herencia {% extends 'base.html.twig' %} + {% block body %} + {{ parent() }}
Incluir {{ include('article/_card.html.twig', {article: a}) }}
Embed {% embed '_panel.html.twig' %}{% block content %}{% endblock %}{% endembed %}
Macro {% macro badge(t) %}...{% endmacro %} + {% import '...' as ui %}
Globales app.user, app.request, app.flashes, app.environment, twig.globals
Filtro propio #[AsTwigFilter('nombre')] en un método
Función propia #[AsTwigFunction('nombre')] en un método
Twig Component #[AsTwigComponent] + templates/components/X.html.twig + <twig:X :prop="v" />
Componente anónimo {% props a, b = 'x' %}
Live Component #[AsLiveComponent], DefaultActionTrait, #[LiveProp(writable: true)], data-model
Escape Automático; \|e('js'), \|e('html_attr'), \|url_encode; \|raw con cuidado
Depurar {{ dump(x) }}, debug:twig, lint:twig