The 7 bad Twig practices that are breaking your Drupal (and how to fix them)

The 7 bad Twig practices that are breaking your Drupal (and how to fix them)

After several years working with Drupal 8, 9, 10 and 11, and reviewing code from projects inherited from freelancers, agencies or simply left to fend for themselves, there's a pattern that keeps repeating: Twig templates that seem to work but are silently causing performance, cache and security issues.

The problem is that many of these errors aren't obvious until you have a site with real traffic or until a client tells you that the changes they make to content aren't showing up on the page. And that's when the debugging hours begin.

I'm going to go through the most common anti-patterns I come across, explain why they're problematic and how to fix them following the practices recommended by the Drupal community.

The principle that governs everything

Twig is for presentation, preprocess is for logic. If you internalize this phrase, you'll avoid 80% of problems. Twig templates should only contain simple display logic: showing or hiding elements, iterating over lists, applying conditional CSS classes. Everything else should be resolved before reaching the template.

1. Drilling into render arrays: the most common and most harmful error

This is something I see constantly and that causes more problems than people realize:

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

Why is it problematic?

When you directly access the internal keys of a render array, you're bypassing Drupal's entire rendering system. This has several serious consequences:

  • You lose cache bubbling: Cache tags, contexts and max-age don't propagate upward. Drupal doesn't know that part of the page depends on that data, so it doesn't invalidate the cache when it changes.
  • You ignore access checks: Drupal has an entire #access system that determines if the current user can see that content. By drilling in, you're completely ignoring it.
  • Your code is fragile: The internal structure of render arrays can change between Drupal versions or module versions. What works today might break tomorrow.

The correct solution

Always render the complete field:

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

If you need to control how the field is displayed, do it through view modes or create a custom field formatter. That's how it's supposed to be done.

2. Not rendering the content variable (or doing it partially)

This is a classic. You have a template where you only want to show some specific fields:

{# BAD: content is never fully rendered #}
<h1>{{ label }}</h1>
{{ content.body }}
{{ content.field_image }}

The problem here is that the content variable contains cache metadata from all fields, not just the ones you're rendering. If you don't render content in its entirety, those metadata never bubble up and Drupal can't properly invalidate the cache.

The solution: the |without filter

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

With |without you're rendering the rest of content (including all its cache metadata) but excluding the fields you've already rendered manually. This way you avoid visual duplicates and keep the cache system working.

3. The |raw filter: the door to XSS vulnerabilities

One of the great advantages of Twig is that it automatically escapes all content. This means that if a malicious user enters <script>alert('hacked')</script> in a field, Twig will convert it to harmless plain text.

But when you use |raw, you disable this escaping:

{{ node.field_user_content.value|raw }}

If that field contains data entered by users, you've just opened an XSS vulnerability. An attacker could inject JavaScript that steals sessions, redirects to malicious sites or anything else.

When is it safe to use |raw?

Practically never with user data. The only acceptable cases are:

  • Content that has already been processed and marked as safe by Drupal (text format fields that go through the filter system)
  • HTML generated by your own code in preprocess that you know is safe

The correct alternative

Render the field through Drupal's system, which already applies the configured text filters:

{{ content.field_user_content }}

4. Putting business logic in templates

Sometimes the temptation is strong. You need to calculate something, verify a complex condition, and you think: "after all, Twig can do it". And yes, technically it can:

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

The problems with this approach

  • You can't test it: Business logic should be unit testable. In Twig, it's impossible.
  • Incorrect cache contexts: Drupal doesn't know that this part of the page depends on the current user and their roles. The cache might serve incorrect content.
  • Duplicated code: If you need the same logic in another template, you'll have to copy it.
  • Hard to maintain: When a themer looks for a logic bug, they shouldn't have to review templates.

The solution: move the logic to preprocess

In your .theme file:

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) . '€';
  }
  
  // The correct cache context for system roles
  $variables['#cache']['contexts'][] = 'user.roles';
}

And in your template:

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

5. Including components without context isolation

With the arrival of Single Directory Components (SDC) in Drupal 10.3, it's increasingly common to use reusable components. But there's a very frequent error:

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

The problem is that without with_context = false, the component receives ALL variables from the parent context. This causes:

  • Name collisions: If your component uses a 'content' variable internally, it might clash with the 'content' variable from the parent template.
  • Non-reusable components: The component implicitly depends on variables it expects to find in context, making it impossible to use elsewhere.
  • Hard-to-trace bugs: Unexpected behaviors because the component is using variables that shouldn't reach it.

The correct way

For simple includes:

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

For components with slots (content blocks):

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

The only keyword ensures that only explicitly passed variables will be available inside the component.

6. Building HTML attributes as strings

It's very common to see this in templates:

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

The problem is that Drupal provides an Attribute object precisely to handle HTML attributes safely. When you build classes manually:

  • You lose all attributes that other modules might have added (data-*, aria-*, etc.)
  • You don't properly escape values (potential XSS vulnerabilities)
  • The code is less readable and more error-prone

Use the Attribute object

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

Also notice the use of |clean_class. This filter converts any string into a valid CSS class, escaping special characters and spaces. You should always use it when generating dynamic classes.

7. Accessing entities directly without forcing cache bubbling

Another problematic pattern I see frequently:

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

Here you're accessing the file entity directly, but Drupal has no way of knowing you did. The cache tags from that media entity won't propagate, so if someone changes the image, the cached URL will still point to the old image.

The solution: force bubbling first

{# Force cache metadata to bubble #}
{% set _cache = content.field_image|render %}
{# Now we can safely access the entity #}
{{ file_url(node.field_image.entity.uri.value) }}

By rendering the field first (even though we assign the result to a throwaway variable), we force Drupal to process all cache metadata. Now, when the image changes, the cache will be properly invalidated.

Bonus: Props vs Slots in SDC components

Since we're talking about components, a frequent question is when to use props (properties) and when to use slots (content blocks). Here's a simple rule:

Use Props for:

  • Simple strings (titles, labels)
  • Numbers and booleans
  • URLs
  • Formatted dates
  • CSS variants or modifiers

Use Slots for:

  • Fields with formatting (body, formatted text)
  • Images and media
  • Rendered entity references
  • Nested components
  • Any complex HTML content

And very importantly: never pass the entire node or content object to a component. That couples your component to Drupal's structure and makes it impossible to reuse outside that specific context.

Quick checklist for reviewing your templates

Before signing off on a template, verify these points:

  • Am I accessing internal render array keys ([0], ['#markup'], etc.)?
  • Am I rendering content or using |without?
  • Is there business logic that should be in preprocess?
  • Am I using |raw with data that could come from users?
  • Do my component includes use with_context = false or only?
  • Am I using the Attribute object for HTML attributes?
  • Am I using |clean_class for dynamic CSS classes?
  • If I'm accessing entities directly, have I forced cache bubbling?

To wrap up

Most of these problems have something in common: they're silent. Your site will work, it will pass code review if nobody's looking closely, and even tests might pass. But when real traffic arrives, when editors start complaining that changes aren't showing up, or when a security report appears, that's when you'll realize.

The good news is that once you internalize these patterns, they become automatic. And your code will be cleaner, more maintainable and more robust.

If this article has been useful to you, share it with your frontend team or with that person who's always fighting with Drupal's cache system. Sometimes the difference between a project that gives you headaches and one that works well lies in these small details.

Tags

Have Any Project in Mind?

If you want to do something in Drupal maybe you can hire me.

Either for consulting, development or maintenance of Drupal websites.