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 - Isolationonly 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