Las 7 malas prácticas en Twig que están rompiendo tu Drupal (y cómo solucionarlas)

Después de varios años trabajando con Drupal 8, 9, 10 y 11, y revisando código de proyectos heredados de freelancers, agencias o simplemente abandonados a su suerte, hay un patrón que se repite constantemente: plantillas Twig que parecen funcionar pero que están causando problemas silenciosos de rendimiento, caché y seguridad.

El problema es que muchos de estos errores no son evidentes hasta que tienes una web con tráfico real o hasta que un cliente te dice que los cambios que hace en el contenido no se reflejan en la página. Y ahí empiezan las horas de debug.

Voy a repasar los anti-patterns más comunes que me encuentro, explicar por qué son problemáticos y cómo corregirlos siguiendo las prácticas recomendadas por la comunidad Drupal.

El principio que lo gobierna todo

Twig es para presentación, preprocess para lógica. Si interiorizas esta frase, evitarás el 80% de los problemas. Las plantillas Twig solo deberían contener lógica de visualización simple: mostrar u ocultar elementos, iterar sobre listas, aplicar clases CSS condicionales. Todo lo demás debería resolverse antes de llegar a la plantilla.

1. Perforar render arrays: el error más común y más dañino

Esto es algo que veo constantemente y que causa más problemas de los que la gente imagina:

{{ content.field_image[0]['#markup'] }}
{{ content.field_body['#items'][0]['value'] }}
{{ content.field_link[0]['#url'] }}

¿Por qué es problemático?

Cuando accedes directamente a las claves internas de un render array, estás saltándote todo el sistema de renderizado de Drupal. Esto tiene varias consecuencias graves:

  • Pierdes el cache bubbling: Los cache tags, contexts y max-age no se propagan hacia arriba. Drupal no sabe que esa parte de la página depende de esos datos, así que no invalida la caché cuando cambian.
  • Ignoras las verificaciones de acceso: Drupal tiene todo un sistema de #access que determina si el usuario actual puede ver ese contenido. Al perforar, lo estás ignorando completamente.
  • Tu código es frágil: La estructura interna de los render arrays puede cambiar entre versiones de Drupal o de módulos. Lo que hoy funciona, mañana puede romperse.

La solución correcta

Siempre renderiza el campo completo:

{{ content.field_image }}
{{ content.body }}
{{ content.field_link }}

Si necesitas controlar cómo se muestra el campo, hazlo a través de los modos de visualización o crea un field formatter personalizado. Así es como se supone que debe hacerse.

2. No renderizar la variable content (o hacerlo parcialmente)

Este es un clásico. Tienes una plantilla donde solo quieres mostrar algunos campos específicos:

{# MAL: content nunca se renderiza completamente #}
<h1>{{ label }}</h1>
{{ content.body }}
{{ content.field_image }}

El problema aquí es que la variable content contiene metadatos de caché de todos los campos, no solo de los que estás renderizando. Si no renderizas content en su totalidad, esos metadatos nunca hacen bubbling y Drupal no puede invalidar correctamente la caché.

La solución: el filtro |without

<h1>{{ label }}</h1>
{{ content.body }}
{{ content.field_image }}
{{ content|without('body', 'field_image') }}

Con |without estás renderizando el resto de content (incluyendo todos sus metadatos de caché) pero excluyendo los campos que ya has renderizado manualmente. Así evitas duplicados visuales y mantienes el sistema de caché funcionando.

3. El filtro |raw: la puerta a las vulnerabilidades XSS

Una de las grandes ventajas de Twig es que escapa automáticamente todo el contenido. Esto significa que si un usuario malicioso introduce <script>alert('hacked')</script> en un campo, Twig lo convertirá en texto plano inofensivo.

Pero cuando usas |raw, desactivas este escapado:

{{ node.field_user_content.value|raw }}

Si ese campo contiene datos introducidos por usuarios, acabas de abrir una vulnerabilidad XSS. Un atacante podría inyectar JavaScript que robe sesiones, redirija a sitios maliciosos o cualquier otra cosa.

¿Cuándo es seguro usar |raw?

Prácticamente nunca con datos de usuario. Los únicos casos aceptables son:

  • Contenido que ya ha sido procesado y marcado como seguro por Drupal (campos con formato de texto que pasan por el sistema de filtros)
  • HTML generado por tu propio código en preprocess y que sabes que es seguro

La alternativa correcta

Renderiza el campo a través del sistema de Drupal, que ya aplica los filtros de texto configurados:

{{ content.field_user_content }}

4. Meter lógica de negocio en las plantillas

A veces la tentación es grande. Necesitas calcular algo, verificar una condición compleja, y piensas: "total, Twig puede hacerlo". Y sí, técnicamente puede:

{% if 'premium' in user.roles %}
  {% set discount = product.price.value * 0.2 %}
  <span class="discount">Ahorra {{ discount }}€</span>
{% endif %}

Los problemas de este enfoque

  • No puedes testear: La lógica de negocio debería poder testearse de forma unitaria. En Twig, es imposible.
  • Cache contexts incorrectos: Drupal no sabe que esta parte de la página depende del usuario actual y sus roles. La caché puede servir contenido incorrecto.
  • Código duplicado: Si necesitas la misma lógica en otra plantilla, tendrás que copiarla.
  • Difícil de mantener: Cuando un themer busca un bug de lógica, no debería tener que revisar plantillas.

La solución: mueve la lógica a preprocess

En tu archivo .theme:

function mytheme_preprocess_node(&$variables) {
  $node = $variables['node'];
  $user = \Drupal::currentUser();
  
  $is_premium = in_array('premium', $user->getRoles());
  $variables['has_premium_discount'] = $is_premium;
  
  if ($is_premium) {
    $price = $node->get('field_price')->value;
    $variables['formatted_discounted_price'] = number_format($price * 0.8, 2) . '€';
  }
  
  // El cache context correcto para roles del sistema
  $variables['#cache']['contexts'][] = 'user.roles';
}

Y en tu plantilla:

{% if has_premium_discount %}
  <span class="discount">{{ formatted_discounted_price }}</span>
{% endif %}

5. Incluir componentes sin aislamiento de contexto

Con la llegada de los Single Directory Components (SDC) en Drupal 10.3, cada vez es más común usar componentes reutilizables. Pero hay un error muy frecuente:

{{ include('mytheme:card', {heading: title}) }}

El problema es que sin with_context = false, el componente recibe TODAS las variables del contexto padre. Esto causa:

  • Colisiones de nombres: Si tu componente usa una variable 'content' internamente, puede chocar con la variable 'content' de la plantilla padre.
  • Componentes no reutilizables: El componente depende implícitamente de variables que espera encontrar en el contexto, haciéndolo imposible de usar en otros lugares.
  • Bugs difíciles de rastrear: Comportamientos inesperados porque el componente está usando variables que no deberían llegarle.

La forma correcta

Para includes simples:

{{ include('mytheme:card', {heading: title}, with_context = false) }}

Para componentes con slots (bloques de contenido):

{% embed 'mytheme:card' with {heading: title} only %}
  {% block body %}{{ content.body }}{% endblock %}
  {% block media %}{{ content.field_image }}{% endblock %}
{% endembed %}

La palabra clave only garantiza que solo las variables explícitamente pasadas estarán disponibles dentro del componente.

6. Construir atributos HTML como strings

Es muy común ver esto en plantillas:

<article class="node node--{{ node.bundle }}">

El problema es que Drupal proporciona un objeto Attribute precisamente para manejar atributos HTML de forma segura. Cuando construyes las clases a mano:

  • Pierdes todos los atributos que otros módulos podrían haber añadido (data-*, aria-*, etc.)
  • No escapas correctamente los valores (vulnerabilidades XSS potenciales)
  • El código es menos legible y más propenso a errores

Usa el objeto Attribute

{% set classes = [
  'node',
  'node--' ~ node.bundle|clean_class,
  node.isPromoted ? 'node--promoted' : '',
] %}
<article{{ attributes.addClass(classes) }}>

Fíjate también en el uso de |clean_class. Este filtro convierte cualquier string en una clase CSS válida, escapando caracteres especiales y espacios. Siempre deberías usarlo cuando generas clases dinámicamente.

7. Acceder a entidades directamente sin forzar el cache bubbling

Otro patrón problemático que veo frecuentemente:

{{ file_url(node.field_image.entity.uri.value) }}

Aquí estás accediendo a la entidad del fichero directamente, pero Drupal no tiene forma de saber que lo has hecho. Los cache tags de esa entidad media no se propagarán, así que si alguien cambia la imagen, la URL cacheada seguirá apuntando a la imagen antigua.

La solución: forzar el bubbling primero

{# Forzamos que los metadatos de caché burbujeen #}
{% set _cache = content.field_image|render %}
{# Ahora podemos acceder a la entidad con seguridad #}
{{ file_url(node.field_image.entity.uri.value) }}

Al renderizar el campo primero (aunque asignemos el resultado a una variable desechable), forzamos que Drupal procese todos los metadatos de caché. Ahora sí, cuando la imagen cambie, la caché se invalidará correctamente.

Bonus: Props vs Slots en componentes SDC

Ya que hablamos de componentes, una duda frecuente es cuándo usar props (propiedades) y cuándo usar slots (bloques de contenido). Aquí va una regla simple:

Usa Props para:

  • Strings simples (títulos, labels)
  • Números y booleanos
  • URLs
  • Fechas formateadas
  • Variantes o modificadores CSS

Usa Slots para:

  • Campos con formateo (body, texto formateado)
  • Imágenes y media
  • Entity references renderizadas
  • Componentes anidados
  • Cualquier contenido HTML complejo

Y muy importante: nunca pases el objeto node o content entero a un componente. Eso acopla tu componente a la estructura de Drupal y lo hace imposible de reutilizar fuera de ese contexto específico.

Checklist rápido para revisar tus plantillas

Antes de dar por buena una plantilla, verifica estos puntos:

  • ¿Estoy accediendo a claves internas de render arrays ([0], ['#markup'], etc.)?
  • ¿Estoy renderizando content o usando |without?
  • ¿Hay lógica de negocio que debería estar en preprocess?
  • ¿Uso |raw con datos que podrían venir de usuarios?
  • ¿Mis includes de componentes usan with_context = false u only?
  • ¿Uso el objeto Attribute para los atributos HTML?
  • ¿Estoy usando |clean_class para clases CSS dinámicas?
  • Si accedo a entidades directamente, ¿he forzado el bubbling de caché?

Para cerrar

La mayoría de estos problemas tienen algo en común: son silenciosos. Tu web funcionará, pasará el code review si nadie está mirando con lupa, e incluso los tests pueden pasar. Pero cuando llegue el tráfico real, cuando los editores empiecen a quejarse de que los cambios no se ven, o cuando aparezca un reporte de seguridad, ahí te darás cuenta.

La buena noticia es que una vez interiorizas estos patrones, se convierten en automáticos. Y tu código será más limpio, más mantenible y más robusto.

Si este artículo te ha sido útil, compártelo con tu equipo de frontend o con esa persona que siempre está luchando con el sistema de caché de Drupal. A veces la diferencia entre un proyecto que da guerra y uno que funciona bien está en estos pequeños detalles.

Tags

¿Tienes algún proyecto en mente?

Si quieres hacer algo en Drupal tal vez puedas contratarme.

Ya sea para consultoría, desarrollo o mantenimiento de sitios web Drupal.