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.twigfiles, 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).
Two consequences follow from this design:
-
The container is compiled and cached. A new
twig.extensionservice is not noticed until the container is rebuilt, which in practice means clearing the caches (drush cr) after adding theservices.ymlentry or changing its arguments. Editing the body of an existing method does not need a rebuild of the container. -
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.
Source: Creating custom Twig extensions
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.
Source: Twig: Extending Twig
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) |
|
Dependencies are explicit; the class is testable with mocks; no hidden global state |
Static container call |
|
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) |
|
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 |
|---|---|
|
|
|
A callable that decides, from the argument node, whether the output is safe. Core uses it for |
|
Passes the |
|
Passes the array of template variables (after the environment, if both are set). |
|
Collects extra arguments into a trailing array. |
|
Escapes the input before the filter runs (filters only). |
|
Marks the callable as deprecated with a |
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.
Source: Twig: Extending Twig
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:
-
return a render array with
#cache(tags, contexts,max-age); -
return an object that implements
CacheableDependencyInterface, whose metadata Drupal bubbles when the object is printed; -
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.extensiontag, the caches were not cleared after adding the service, the module is not installed, or the name inTwigFilter('...')differs from the one in the template. Rundrush cranddrush pm:list --status=enabledfirst. -
Fatal error about a missing argument or an unresolvable autowire. The
argumentslist 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 examplemeridian_) 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.extensiontag andservice_collectormechanism 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-extrafilters (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\TwigExtensionis an internal class; use it as a reference for patterns, but do not extend it or depend on its method names.