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
ifyfor(incluidoselsey la variableloop) - Aplicar herencia de plantillas con
extends,blockyparent() - Reutilizar código con
include,embedy macros - Usar las variables globales de
appy 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:
- Si
articlees un array, la clavetitle.- Si es un objeto, la propiedad pública
title.- El método
title().- El método
getTitle().- El método
isTitle()y luegohasTitle().- Si nada existe,
null(o error sistrict_variablesestá 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
devTwig no falla por defecto (devuelvenull), pero en el entornotestla recipe activastrict_variables: truey sí lanzará error. Usais definedo??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-extray usaformat_datetime,format_date,format_numberoformat_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">
© {{ '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:twigpara 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 filtrofilter:{% 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>© {{ '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 condebug:twig --filter=readingque 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]ydata-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: <script>alert("hackeado")</script> → 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
|rawcon 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 filtrosanitize_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 <b> 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 |