Skip to content

Twig and preprocess best practices

7.5 Twig and Preprocess Best Practices

When to Use This Section

  • You're deciding whether logic belongs in Twig or preprocess function
  • You need guidance on Twig template inheritance (extend vs include vs embed)
  • You're working with the Drupal attributes object
  • You want performance best practices for Twig templates

Preprocess vs Twig: What Belongs Where

CRITICAL PRINCIPLE: Separation of Concerns

PREPROCESS FUNCTIONS (.theme file): Logic and data manipulation TWIG TEMPLATES (.twig file): Presentation and markup

Decision Framework: Preprocess or Twig?

Task Preprocess Function Twig Template WHY
Data transformation ✅ ❌ Complex logic doesn't belong in templates
Array manipulation ✅ ❌ PHP is better at array operations
Database queries ✅ ❌ Never query in templates
Conditional logic ❌ ✅ Simple conditionals are fine in Twig
Loops ❌ ✅ Twig is designed for iteration
Filters (t, url, date) ❌ ✅ Twig filters optimize better than PHP
Adding CSS classes ✅ ✅ Both work; preprocess for complex logic
Render arrays ✅ ❌ Return render arrays, don't call drupal_render()

Pattern: What Goes in Preprocess

File: THEME_NAME.theme

<?php

/**
 * Implements hook_preprocess_node().
 */
function THEME_NAME_preprocess_node(&$variables) {
  $node = $variables['node'];

  // GOOD: Data transformation
  $variables['formatted_date'] = \Drupal::service('date.formatter')
    ->format($node->getCreatedTime(), 'custom', 'F j, Y');

  // GOOD: Complex conditional class logic
  $variables['attributes']['class'][] = 'node';
  $variables['attributes']['class'][] = 'node--type-' . $node->bundle();
  if (!$node->isPublished()) {
    $variables['attributes']['class'][] = 'node--unpublished';
  }

  // GOOD: Adding contextual information
  $variables['author_name'] = $node->getOwner()->getDisplayName();

  // GOOD: Preparing render arrays for template
  $variables['share_buttons'] = [
    '#theme' => 'share_buttons',
    '#node' => $node,
  ];

  // BAD: Don't render in preprocess
  // $variables['content'] = \Drupal::service('renderer')->render($content);
}

WHY IN PREPROCESS: - Complexity — Data transformation logic doesn't clutter template - Testability — Can unit test PHP functions - Performance — PHP is faster than Twig for complex operations - Reusability — Logic can be shared across templates

Pattern: What Goes in Twig

File: templates/content/node--article.html.twig

{#
/**
 * @file
 * Theme override for article nodes.
 *
 * Available variables:
 * - formatted_date: Pre-formatted date string from preprocess
 * - author_name: Author display name from preprocess
 */
#}

{# GOOD: Simple conditional rendering #}
{% if author_name %}
  <div class="node__author">{{ 'By @name'|t({'@name': author_name}) }}</div>
{% endif %}

{# GOOD: Using Twig filters #}
<time datetime="{{ node.created.value|date('c') }}">
  {{ formatted_date }}
</time>

{# GOOD: Looping through content #}
{% for item in content.field_tags %}
  <span class="tag">{{ item }}</span>
{% endfor %}

{# GOOD: Using url() filter in template #}
<a href="{{ url('entity.node.canonical', {'node': node.id}) }}">
  {{ 'Read more'|t }}
</a>

{# BAD: Don't do complex logic in Twig #}
{% set complex_calculation = some_value * 100 / total_value %}
{# This should be in preprocess #}

WHY IN TWIG: - Presentation focus — Templates focus on markup - Performance — Twig filters like t() and url() optimize better than calling them in preprocess (lazy evaluation — only executed if actually printed) - Readability — Frontend developers can work on templates without PHP knowledge - Caching — Twig templates cache well

Twig Template Inheritance: Extend vs Include vs Embed

Pattern: {% extends %} for Template Hierarchy

WHEN TO USE: Base template with blocks that child templates override

File: templates/layout/page--base.html.twig (Base template)

<div class="page-wrapper">
  <header class="page-header">
    {% block header %}
      {# Default header content #}
    {% endblock %}
  </header>

  <main class="page-content">
    {% block content %}
      {# Default content #}
    {% endblock %}
  </main>

  <footer class="page-footer">
    {% block footer %}
      {# Default footer content #}
    {% endblock %}
  </footer>
</div>

File: templates/layout/page--front.html.twig (Child template)

{% extends "page--base.html.twig" %}

{% block header %}
  {# Custom front page header #}
  <div class="hero-section">
    {{ page.header }}
  </div>
{% endblock %}

{% block content %}
  {# Keep parent content and add more #}
  {{ parent() }}
  <div class="featured-content">
    {# Additional front page content #}
  </div>
{% endblock %}

{# footer block inherits default from base #}

WHY USE EXTENDS: - DRY principle — Share common structure across templates - Consistency — All pages use same base wrapper - Maintainability — Update base template, all children inherit changes

CRITICAL: Template can only extend ONE parent, and {% extends %} must be first tag in template.


Pattern: {% include %} for Reusable Snippets

WHEN TO USE: Drop in a component or snippet without inheritance

File: templates/content/node--article.html.twig

<article{{ attributes }}>

  {# Include share buttons component #}
  {% include 'THEME_NAME:share-buttons' with {
    'url': url('entity.node.canonical', {'node': node.id}),
    'title': label,
  } only %}

  {# Include breadcrumb #}
  {% include '@radix/breadcrumb/breadcrumb.twig' with {
    'breadcrumb': breadcrumb,
  } %}

  <div class="node__content">
    {{ content }}
  </div>
</article>

WHY USE INCLUDE: - Composition — Build pages from components - Reusability — Same snippet used in multiple templates - Isolation — only keyword prevents variable bleed

TIP: Use only keyword to explicitly pass only specified variables:

{# GOOD: Explicit variable passing #}
{% include 'component.twig' with {'title': title} only %}

{# RISKY: All template variables passed #}
{% include 'component.twig' %}

Pattern: {% embed %} for Extending Included Templates

WHEN TO USE: Include a template BUT override some of its blocks

File: components/card/card.twig

<div class="card">
  <div class="card-header">
    {% block card_header %}
      <h3>{{ title }}</h3>
    {% endblock %}
  </div>

  <div class="card-body">
    {% block card_body %}
      {{ content }}
    {% endblock %}
  </div>

  <div class="card-footer">
    {% block card_footer %}
      {# Default footer #}
    {% endblock %}
  </div>
</div>

File: templates/node--teaser.html.twig

{% embed 'THEME_NAME:card' with {'title': label} %}

  {% block card_body %}
    {# Override card body #}
    {{ content.field_image }}
    {{ content.body }}
  {% endblock %}

  {% block card_footer %}
    {# Custom footer for node teaser #}
    <a href="{{ url }}" class="btn btn-primary">
      {{ 'Read more'|t }}
    </a>
  {% endblock %}

{% endembed %}

WHY USE EMBED: - Flexible composition — Include base structure, customize parts - DRY principle — Reuse card structure, override specific sections - Powerful pattern — Combines benefits of extend and include

COMPARISON:

Method Purpose Can Override Blocks? Variable Scope
{% extends %} Template hierarchy ✅ Inherits all
{% include %} Drop in snippet ❌ Explicit with with
{% embed %} Include + override blocks ✅ Explicit with with

Attributes Object Best Practices

CRITICAL RULE: Always Use attributes.addClass()

Pattern: Proper Attribute Usage

{# GOOD: Use attributes object #}
{%
  set classes = [
    'node',
    'node--type-' ~ node.bundle|clean_class,
    node.isPromoted() ? 'node--promoted',
    node.isSticky() ? 'node--sticky',
    not node.isPublished() ? 'node--unpublished',
    view_mode ? 'node--view-mode-' ~ view_mode|clean_class,
  ]
%}

<article{{ attributes.addClass(classes) }}>
  {{ content }}
</article>

WHY: - Drupal integration — Drupal can add contextual classes, IDs, data attributes - Module compatibility — Contrib modules can modify attributes - Extensibility — Other code can add attributes without template changes

BAD PRACTICE: Hardcoded classes

{# BAD: Hardcoded classes prevent Drupal from adding attributes #}
<article class="node node--article node--promoted">
  {{ content }}
</article>

WHY BAD: - Loss of functionality — Drupal can't add contextual editing, quickedit, contextual links - Module conflicts — Modules that depend on attributes won't work - Inflexible — Can't add custom attributes without template changes

Pattern: Merging Custom Attributes

{# Scenario: Component receives attributes prop #}
{%
  set classes = [
    'my-component',
    variant ? 'my-component--' ~ variant : '',
  ]
%}

{# Merge component classes with passed attributes #}
<div {{ attributes.addClass(classes) }}>
  {{ slots.content }}
</div>

WHY: Allows callers to add their own classes/attributes without overriding component classes.

Pattern: Separating Title and Content Attributes

{# Common in node templates #}
<article{{ attributes }}>

  <h2{{ title_attributes.addClass('node__title') }}>
    <a href="{{ url }}">{{ label }}</a>
  </h2>

  <div{{ content_attributes.addClass('node__content') }}>
    {{ content }}
  </div>

</article>

WHY: Different elements may have different contextual attributes from Drupal.

Twig Debug Mode and Performance

Development vs Production Settings

Development: Enable Twig Debugging

File: sites/default/services.yml

parameters:
  twig.config:
    debug: true
    auto_reload: true
    cache: false

WHAT THIS DOES: - debug: true — Shows template suggestions in HTML comments - auto_reload: true — Recompiles templates on every page load - cache: false — Doesn't cache compiled templates

WHEN TO USE: Local development only

WHY: See which template is rendering, find template suggestions, no need to clear cache after changes.


Production: Disable Twig Debugging

parameters:
  twig.config:
    debug: false
    auto_reload: false
    cache: true

WHAT THIS DOES: - debug: false — No HTML comments (cleaner markup, better performance) - auto_reload: false — Only recompile if template modified - cache: true — Cache compiled PHP from Twig

WHEN TO USE: Staging and production

WHY: Performance. Twig compilation is expensive; caching drastically improves speed.

PERFORMANCE IMPACT: - Debug on: ~20-30% slower page loads - Cache off: ~50-70% slower (recompiles every page load) - Production settings: Optimal performance

Common Mistakes Senior Themers Catch in Code Review

1. Not returning render arrays from preprocess

// BAD: Calling renderer in preprocess
function mytheme_preprocess_node(&$variables) {
  $content = ['#markup' => '<div>Content</div>'];
  $variables['rendered_content'] = \Drupal::service('renderer')->render($content);
}

WHY BAD: Twig renders automatically. Pre-rendering prevents caching optimization and breaks lazy rendering.

CORRECT:

// GOOD: Return render array
function mytheme_preprocess_node(&$variables) {
  $variables['rendered_content'] = [
    '#markup' => '<div>Content</div>',
  ];
}

2. Calling theme() or drupal_render() in preprocess

// BAD: Legacy Drupal 7 patterns
function mytheme_preprocess_node(&$variables) {
  $variables['custom'] = theme('item_list', ['items' => $items]);
}

WHY BAD: These are Drupal 7 functions. Drupal 8+ uses render arrays.

CORRECT:

// GOOD: Use render arrays
function mytheme_preprocess_node(&$variables) {
  $variables['custom'] = [
    '#theme' => 'item_list',
    '#items' => $items,
  ];
}

3. Complex logic in Twig templates

{# BAD: Complex calculation in Twig #}
{% set percentage = (completed_count / total_count * 100)|round %}
{% set status_class = percentage > 80 ? 'success' : (percentage > 50 ? 'warning' : 'danger') %}

WHY BAD: Difficult to test, debug, and maintain. Twig is not designed for complex logic.

CORRECT:

// GOOD: Logic in preprocess
function mytheme_preprocess_progress_bar(&$variables) {
  $completed = $variables['completed_count'];
  $total = $variables['total_count'];
  $percentage = round(($completed / $total) * 100);

  if ($percentage > 80) {
    $status_class = 'success';
  } elseif ($percentage > 50) {
    $status_class = 'warning';
  } else {
    $status_class = 'danger';
  }

  $variables['percentage'] = $percentage;
  $variables['status_class'] = $status_class;
}
{# GOOD: Simple rendering in Twig #}
<div class="progress-bar progress-bar--{{ status_class }}">
  {{ percentage }}%
</div>

4. Using PHP functions directly in Twig

{# BAD: Twig doesn't have direct PHP function access (security) #}
<a href="/node/{{ node.id }}">
  {{ label }}
</a>

CORRECT:

{# GOOD: Use Twig filters #}
<a href="{{ path('entity.node.canonical', {'node': node.id}) }}">
  {{ label }}
</a>

{# Or use url() function #}
<a href="{{ url('entity.node.canonical', {'node': node.id}) }}">
  {{ label }}
</a>

WHY: Twig filters are security-hardened and cache-aware. Direct PHP is blocked.


5. Not using trans filter for strings

{# BAD: Hardcoded English string #}
<button>Read more</button>

CORRECT:

{# GOOD: Translatable string #}
<button>{{ 'Read more'|t }}</button>

WHY: Makes site translatable. Even single-language sites benefit from centralized string management.


6. Forgetting to check variable existence

{# BAD: Will error if author_name doesn't exist #}
<div class="author">{{ author_name }}</div>

CORRECT:

{# GOOD: Check existence first #}
{% if author_name is defined and author_name %}
  <div class="author">{{ author_name }}</div>
{% endif %}

{# Or use default filter #}
<div class="author">{{ author_name|default('Anonymous') }}</div>

7. Not using 'only' with includes

{# RISKY: Passes all variables (potential variable bleed) #}
{% include 'component.twig' with {'title': title} %}

CORRECT:

{# GOOD: Explicit variable isolation #}
{% include 'component.twig' with {'title': title} only %}

WHY: Prevents accidental variable collisions and makes variable dependencies explicit.

See Also