Twig Extensions in Modules for Drupal Themes

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

A Twig template can only call the filters and functions that the twig service knows about. Drupal core and the Twig library supply a generous set (t, clean_class, format_date, attach_library, path and so on, covered in Twig templates and debugging), but sooner or later a theme needs something that is not there: formatting an accession number, counting objects that are on display, or choosing an icon name from a field value. Putting that logic in a hook_preprocess function works for one template; a Twig extension makes it available in every template of every theme, with the dependencies it needs injected properly.

This page was checked against the official documentation and the Drupal 11.x source on 2026-10-30. It is written against Drupal 11.4.x, Drush 13 or 14 and PHP 8.3 or later (see Requirements and hosting). The examples belong to the fictitious Meridian Museum site from the sub-section index: the custom module meridian_collection provides the extension, and the theme meridian uses it. Drupal 10 reaches its end of life on 2026-12-09, so new work should target Drupal 11; Drupal 12.0.0 is planned for the week of 2026-12-07 and the notes below say where this matters.

Why the extension lives in a module

A theme can contain templates, preprocess code and libraries, but it cannot define services in a services file the way a module can: only modules (and install profiles) contribute a MODULE.services.yml to the container. A Twig extension is a PHP class registered as a tagged service, so it belongs in a module. Even when only one theme uses the extension, a small dedicated module (or an existing site module such as meridian_collection) is the right home. The benefits are:

  • the filter or function is available in every theme, in html.twig files, in Single Directory Components (see Single Directory Components) and in any template rendered by Drupal;

  • the class can receive services such as the entity type manager or the date formatter through its constructor, and can be unit tested with mocks;

  • the logic is separated from markup, so a themer who edits a template does not need to touch PHP.

Older books such as Modernizing Drupal 10 Theme Development (2023) describe custom Twig extensions and, like many tutorials of that period, reach for \Drupal:: static calls inside .theme files and extension code. That still works but is no longer the recommended style: from 10.1 core registers autowiring aliases for its common services, and from 11.3 theme code can use object-oriented hooks too (see Hooks and OOP hooks). Static calls to the service container belong only in procedural code that cannot receive a service and in tests.

How the tag works

The twig service (class Drupal\Core\Template\TwigEnvironment) is declared in core/core.services.yml with a service_collector tag. That tag tells the container to find every service tagged twig.extension and call addExtension() on twig for each of them, ordered by the priority attribute of the tag (highest first). Core itself uses the mechanism: its own filters and functions are in Drupal\Core\Template\TwigExtension, registered as a service with the tag twig.extension and priority 100, next to the extensions for Single Directory Components, icons and the twig/html-extra package (priority 101) and the Twig and Symfony VarDumper debug extensions (priorities 50 and 25).

flowchart LR S[meridian_collection.services.yml<br/>service tagged twig.extension] --> C[Container compiler:<br/>service_collector on the twig service] C --> E[TwigEnvironment::addExtension<br/>called once per tagged service] E --> G[getFilters and getFunctions<br/>read by Twig] G --> T[html.twig templates<br/>use the new filter or function] H[Compiler pass: twig_extension_hash<br/>from class names and file hashes] --> E

Two consequences follow from this design:

  1. The container is compiled and cached. A new twig.extension service is not noticed until the container is rebuilt, which in practice means clearing the caches (drush cr) after adding the services.yml entry or changing its arguments. Editing the body of an existing method does not need a rebuild of the container.

  2. Drupal computes a parameter, twig_extension_hash, from the class names of all tagged services and a hash of each class file. It is passed to the Twig environment as part of the template cache key, so changing an extension class invalidates the compiled templates automatically. You do not need to clear the Twig cache by hand after editing an extension.

Extensions that are registered later override definitions with the same name registered earlier: the comment next to the VarDumper extension in the core services file states that, for Twig extensions, the last extension loaded takes precedence. Because the collector sorts by priority from high to low, a tag without priority (equivalent to 0) is loaded after core’s extensions and can replace one of their filters. Do this deliberately and rarely; shadowing a core filter such as t or escape changes the behaviour of every template on the site, including those of contributed modules.

The module

The example module follows the layout described in Module anatomy and the info file:

web/modules/custom/meridian_collection/
├── meridian_collection.info.yml
├── meridian_collection.services.yml
└── src/
    └── Twig/
        └── CollectionTwigExtension.php

Registering the service

# meridian_collection.services.yml
services:
  meridian_collection.twig_extension:
    class: Drupal\meridian_collection\Twig\CollectionTwigExtension
    arguments: ['@entity_type.manager']
    tags:
      - { name: twig.extension }

The service id is only a label; the part that matters is the tag twig.extension. Without it the class is a normal service that nothing loads into Twig. Services files accept the same autowiring syntax as other services, so the definition can also be written without arguments:

services:
  Drupal\meridian_collection\Twig\CollectionTwigExtension:
    autowire: true
    tags:
      - { name: twig.extension }

Autowiring only resolves type hints that have an alias or a service id equal to the class or interface name. Core adds such aliases for many of its services from 10.1 onwards (for example EntityTypeManagerInterface), but a contributed service may not have one; in that case use the explicit arguments form, or the #[Autowire(service: '...')] attribute on the constructor parameter. See Services and dependency injection for the full rules.

The extension class

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Twig;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;

/**
 * Twig filters and functions for the Meridian collection.
 */
final class CollectionTwigExtension extends AbstractExtension {

  public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
  ) {}

  /**
   * {@inheritdoc}
   */
  public function getFilters(): array {
    return [
      new TwigFilter('accession_label', $this->accessionLabel(...)),
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getFunctions(): array {
    return [
      new TwigFunction('on_display_count', $this->onDisplayCount(...)),
    ];
  }

  /**
   * Formats an accession number such as "MM2024042" as "MM-2024-042".
   */
  public function accessionLabel(?string $accession): string {
    if ($accession === NULL || !preg_match('/^([A-Z]{2})(\d{4})(\d{3})$/', $accession, $m)) {
      return (string) $accession;
    }
    return $m[1] . '-' . $m[2] . '-' . $m[3];
  }

  /**
   * Returns a render array with the number of objects on display.
   */
  public function onDisplayCount(): array {
    $storage = $this->entityTypeManager->getStorage('collection_object');
    $count = $storage->getQuery()
      ->accessCheck(TRUE)
      ->condition('on_display', 1)
      ->count()
      ->execute();
    return [
      '#markup' => (string) $count,
      '#cache' => ['tags' => ['collection_object_list']],
    ];
  }

}

Used in a template of the meridian theme, for instance node--exhibition--teaser.html.twig:

<p class="teaser__ref">{{ node.field_accession.value|accession_label }}</p>
<p class="teaser__count">{{ 'Objects on display:'|t }} {{ on_display_count() }}</p>

The extension extends Twig\Extension\AbstractExtension, which supplies empty defaults for every extension method, so a subclass overrides only what it needs: getFilters(), getFunctions(), getTests() for is tests, and getTokenParsers() for new tags. Core’s own extension also overrides getNodeVisitors() and a token parser for the trans tag, which is advanced territory and rarely needed in a project.

Injecting dependencies, not calling \Drupal::

Core’s own TwigExtension is the model: its service definition passes the renderer, the URL generator, the theme manager, the date formatter and the file URL generator as constructor arguments, and its methods use them. A custom extension should do the same.

Approach What it looks like Consequence

Constructor injection (recommended)

private readonly EntityTypeManagerInterface $entityTypeManager plus arguments or autowiring in services.yml

Dependencies are explicit; the class is testable with mocks; no hidden global state

Static container call

\Drupal::entityTypeManager() inside a method

Works, but hides the dependency, makes unit tests need a fake container, and ties the class to the Drupal runtime

Service locator (the whole container)

@service_container as an argument

Avoid: it is the static call in disguise and defeats the purpose of the container

Keep the extension class thin. Classes that do real work (querying, formatting rules, calling an API) are better as ordinary services that the extension calls, so the same logic is available to a controller or a Drush command (see Routing and controllers).

Twig itself also supports runtime classes (RuntimeExtensionInterface) so that an extension whose callables need heavy dependencies is instantiated only when a template actually uses the filter. Drupal’s twig.extension collector takes the extension object directly, which means the extension is constructed (with all of its dependencies) whenever the Twig environment is built, even on requests that render nothing from it. Keep constructor arguments cheap: services are lazy in the sense that they are only created when first requested from the container, but injecting a service that opens a connection or reads files in its own constructor adds that cost to every page that builds Twig.

Filter and function options

TwigFilter and TwigFunction accept a third argument with options. The ones that matter most in Drupal:

Option Meaning

is_safe

['html'] declares that the return value is already HTML-safe, so autoescaping does not escape it again. Core uses it for t, trans, placeholder and safe_join.

is_safe_callback

A callable that decides, from the argument node, whether the output is safe. Core uses it for url and path.

needs_environment

Passes the Twig\Environment as the first argument to the callable.

needs_context

Passes the array of template variables (after the environment, if both are set).

is_variadic

Collects extra arguments into a trailing array.

pre_escape

Escapes the input before the filter runs (filters only).

deprecation_info

Marks the callable as deprecated with a DeprecatedCallableInfo object (Twig 3.15 and later; the older deprecated option is still accepted by earlier versions).

Twig 3.21 and later can also declare filters and functions with the PHP attributes #[AsTwigFilter] and #[AsTwigFunction] and an AttributeExtension. Drupal’s services are plain objects registered through the twig.extension tag, so the straightforward and documented route remains a class that extends AbstractExtension; check the Twig version your site actually runs (composer show twig/twig) before relying on newer Twig features, because drupal/core-recommended no longer pins Twig and the minor you get depends on your lock file.

Output, escaping and cacheability

Escaping

Drupal turns autoescaping on for every template. A filter or function that returns a plain string therefore has that string escaped for HTML, which is what you want for text. Do not set is_safe to make a function "work" when it returns markup built from user input: that is a cross-site scripting vulnerability, because the value reaches the page without escaping. If the function must return markup, build it from render arrays or from Markup/FormattableMarkup objects whose placeholders are escaped, as described in Security hardening. The same rule applies to the |raw filter in templates; see Twig templates and debugging.

Render arrays and cache metadata

A Twig function has no access to the render pipeline’s cacheability bubbling unless the value it returns carries metadata. In the example above, on_display_count() returns a render array with #cache tags, so the template prints it directly rather than passing it as a t() placeholder, which expects a string or number. When the value is printed, Drupal’s render handling (the same code as the render filter) renders the array through the renderer, and the tag collection_object_list bubbles up to the page, so the page cache is invalidated when the list changes. If the function returned a bare integer, the number would be frozen into the cached render of the enclosing block, node or page until something else invalidated it. This is the most common defect in custom Twig extensions: data that changes with content, user, language or time is printed without the cache metadata that says so. The remedies, in order of preference, are:

  1. return a render array with #cache (tags, contexts, max-age);

  2. return an object that implements CacheableDependencyInterface, whose metadata Drupal bubbles when the object is printed;

  3. do the work in a preprocess function or a block plugin that sets cache metadata, and pass the result to the template as a variable, leaving the extension only for pure, deterministic helpers.

Pure functions of their input (formatting an accession number, mapping a value to an icon name) need no cache metadata. Details of the cache system are in Render API and cache metadata and Performance and caching.

Access checks

An entity query in an extension must call accessCheck() explicitly, as the example does. Using accessCheck(FALSE) in a function that anyone can call from a template exposes counts or values derived from content the visitor cannot see. Sort out access first; consider whether the number should be public at all.

Testing the extension

Because the class has no hidden dependencies it can be unit tested without bootstrapping Drupal. With PHPUnit 11, which Drupal 11.2 and later use with attributes instead of annotations:

<?php

declare(strict_types=1);

namespace Drupal\Tests\meridian_collection\Unit\Twig;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\meridian_collection\Twig\CollectionTwigExtension;
use Drupal\Tests\UnitTestCase;
use PHPUnit\Framework\Attributes\Group;

#[Group('meridian_collection')]
final class CollectionTwigExtensionTest extends UnitTestCase {

  public function testAccessionLabel(): void {
    $extension = new CollectionTwigExtension(
      $this->createMock(EntityTypeManagerInterface::class),
    );
    $this->assertSame('MM-2024-042', $extension->accessionLabel('MM2024042'));
    $this->assertSame('unknown', $extension->accessionLabel('unknown'));
  }

}

A kernel test can additionally check that the tag works: render an inline template with the twig service and assert on the output. See Automated testing for test types and how to run them.

Common errors and pitfalls

  • "Unknown filter" or "Unknown function" in a template. The service is missing its twig.extension tag, the caches were not cleared after adding the service, the module is not installed, or the name in TwigFilter('...') differs from the one in the template. Run drush cr and drush pm:list --status=enabled first.

  • Fatal error about a missing argument or an unresolvable autowire. The arguments list does not match the constructor, or autowiring cannot map a type hint to a service. Use explicit service ids.

  • Stale values on the page. The function returns data that depends on content, users or time without cache metadata; see above.

  • Shadowing a core filter by accident. Choosing a filter name that already exists (format_date, render) replaces it for every template when your tag has a lower priority number than core’s. Prefix project filters (for example meridian_) when a clash is possible.

  • Heavy work in the constructor or in functions called per row. A function called once for each item of a view can run hundreds of queries. Batch the data in preprocess and pass it in.

  • Logic that belongs in the template layer’s data. If a filter needs the same data as the template, a preprocess function (see Template suggestions and preprocess) is simpler; an extension pays off when the helper is reused across templates and themes.

  • Putting the class in a theme. A theme cannot register a service. The class and the services file must live in a module (a tiny custom module is fine), and the theme’s documentation should state that the module is required.

Version notes

  • The twig.extension tag and service_collector mechanism have been part of Drupal core since Drupal 8, and the procedure above is unchanged between Drupal 10 and 11.

  • Twig 3 is in use in Drupal 10 and 11. Core ships the twig/html-extra filters (html_cva, html_classes) through its own tagged service from 11.4; the same mechanism is how you can add any official Twig extension package, by declaring its extension class as a tagged service.

  • Core’s Drupal\Core\Template\TwigExtension is an internal class; use it as a reference for patterns, but do not extend it or depend on its method names.