Creating Custom Twig Filters In Drupal Themes
Twig filters are small functions that transform a value before it reaches a Drupal template. Built-in filters such as |clean_class, |lower, |escape, and |render cover many everyday tasks, but a theme or custom module will eventually need a transformation specific to a project.
A custom filter is useful when the same presentation rule appears in several templates. Converting a full name into initials, formatting a support telephone number, generating a readable label, or normalising a CSS modifier are good examples. Keeping that logic in a filter prevents PHP code from being duplicated across preprocess functions and Twig files.
For Australian websites, presentation details can be especially visible in public-facing content. Dates may need to appear as 24/03/2026, prices may use Australian dollars, and local postcodes such as 3000 or 6000 should remain intact when text is transformed. A reusable filter can make these rules consistent across a Drupal installation serving users in Melbourne, Perth, or regional New South Wales.
Twig filters should remain focused on display transformation rather than business logic. If a function needs database queries, access checks, language negotiation, or complex entity loading, a service or preprocess function is generally the better home. The filter should receive a value, transform it predictably, and return a safe result.
Choosing The Right Place For Presentation Logic
Drupal offers several ways to prepare data for a theme. A preprocess function is a strong choice when a template needs a new variable assembled from multiple fields. A Twig filter is better when an existing value needs the same small transformation wherever it is displayed.
For example, a theme might receive an author’s name as $variables['author_name']. A preprocess function could create a separate initials variable, but that creates a new piece of template-specific data. A filter lets any template write:
<span class="profile-badge">{{ author.name|initials }}</span>
The same distinction applies to navigation work. Classes, attributes, and rendered links should generally be prepared using Drupal’s render and attribute systems, while a filter can handle a small textual adjustment. When styling a menu with JavaScript and CSS, this responsive navigation guide is useful context for deciding which behaviour belongs in Twig and which belongs in front-end code.
Avoid using filters to hide important application rules. A filter that silently removes unpublished content, changes permissions, or performs an entity query makes a template difficult to understand and test. It can also cause performance problems because templates may call the filter many times during one page render.
Building A Twig Extension In A Custom Module
Create a custom module rather than placing reusable Twig code in a theme-specific PHP file. A module extension can then be used by several themes, and its behaviour remains available if the site changes from a bespoke theme to a contributed base theme.
A typical file structure could look like this:
web/modules/custom/site_tweaks/
├── site_tweaks.info.yml
├── site_tweaks.services.yml
└── src/
└── Twig/
└── SiteTweaksExtension.php
The extension class should inherit from Twig’s AbstractExtension and return one or more TwigFilter objects. This example creates an initials filter that accepts a name and returns at most three uppercase initials:
<?php
namespace Drupal\site_tweaks\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
final class SiteTweaksExtension extends AbstractExtension {
public function getFilters(): array {
return [
new TwigFilter('initials', [$this, 'initials']),
];
}
public function initials(?string $value): string {
$value = trim((string) $value);
if ($value === '') {
return '';
}
$words = preg_split('/\s+/', $value, -1, PREG_SPLIT_NO_EMPTY);
$initials = '';
foreach (array_slice($words, 0, 3) as $word) {
$initials .= mb_strtoupper(mb_substr($word, 0, 1));
}
return $initials;
}
}
The nullable argument allows the filter to deal cleanly with an empty field. mb_substr() and mb_strtoupper() are preferable to their single-byte equivalents when names contain accented or non-English characters. The method returns plain text, allowing Drupal and Twig to apply normal escaping.
Registering And Using The Filter
Drupal discovers the extension through the module’s service definition. Add the following to site_tweaks.services.yml:
services:
site_tweaks.twig_extension:
class: Drupal\site_tweaks\Twig\SiteTweaksExtension
tags:
- { name: twig.extension }
The service ID can be named differently, but the twig.extension tag is essential. Drupal uses that tag to add the class to the Twig environment. The module’s .info.yml file must also declare a compatible core version, for example:
name: Site Tweaks
type: module
description: Small reusable presentation helpers.
core_version_requirement: ^10 || ^11
package: Custom
After enabling the module, clear Drupal’s caches so the container and compiled Twig templates are rebuilt:
drush en site_tweaks -y
drush cr
The filter is then available in a theme template:
{% set badge_text = author.name|initials %}
<span class="profile-badge" aria-label="{{ author.name }}">
{{ badge_text }}
</span>
The original name is used for the accessible label while the transformed value is shown visually. This is preferable to relying on initials alone, particularly for users who navigate a community site with a screen reader. If a filter is unavailable, Twig will report an unknown filter during rendering, which usually points to a service definition, namespace, module status, or cache rebuild problem.
A filter can accept arguments when the transformation needs controlled options. For example, a truncate_middle filter might receive a maximum length, while a format_aud_phone filter could receive a default country code. Keep the argument list small and document expected input types in the PHPDoc and module tests.
Handling Security, Escaping And Performance
A custom filter must distinguish between text and HTML. Returning a string containing markup does not automatically make that markup safe. A filter that turns & into <strong>&</strong> can create an XSS risk if it marks the result as safe without carefully controlling every input.
For plain text, return a normal string and let Twig autoescape it. Do not use |raw as a shortcut for making a custom filter work. If the output genuinely needs controlled markup, construct it with Drupal’s render API or a carefully reviewed Markup object, and ensure that user-provided content is escaped before it is inserted.
Filters should also be inexpensive. A loop over a short string is fine; loading a node, making an HTTP request, or querying every taxonomy term from inside a filter is not. Those operations can run repeatedly in a listing, view, menu, or paragraph field. Fetch data in a service or preprocess layer, then pass the finished value to Twig.
Caching deserves attention when a filter depends on context. A pure transformation such as lowercasing a string usually requires no special cache handling. A filter that changes output according to the current user, language, route, or time may produce different results for different cache contexts. In that situation, the logic may belong in a renderable build array or preprocess function where Drupal’s cache metadata can be attached explicitly.
Australian sites often need local formatting without sacrificing consistency. A filter that displays AUD 49.95, converts a date to d/m/Y, or formats an Australian phone number should state its assumptions clearly. If the same site later serves New Zealand customers or multiple language markets, hard-coded formatting can become a maintenance problem.
Testing And Maintaining Custom Filters
A small unit test can confirm the important behaviour without rendering a complete page. Test normal names, multiple spaces, empty values, accented characters, and unusually long input. For example, the initials filter should produce JD for Jane Doe, an empty string for a blank value, and a sensible result for a name containing apostrophes or hyphens.
A functional Twig test can verify that Drupal discovers the extension and renders the filter in the expected theme environment. This catches mistakes that a unit test cannot, such as an incorrect service tag or a namespace mismatch. After changing a service definition, always rebuild caches in development and deploy the module configuration through the project’s normal release process.
Use descriptive filter names that read naturally in Twig. |initials and |aud_phone are easier to understand than vague names such as |process_value. Include PHPDoc for input and output expectations, particularly if other developers or content editors will maintain the site. A short README example can prevent a future developer from reimplementing the same rule in a preprocess function.
The following comparison helps identify the most suitable Drupal layer for a transformation:
| Approach | Best suited to | Main benefit | Common risk |
|---|---|---|---|
| Twig filter | Reusable, small value transformations | Clear syntax in templates | Hiding expensive or complex logic |
| Preprocess function | Preparing variables for one template family | Strong control over template data | Duplicated logic across themes |
| Service | Business rules, external data, reusable application logic | Easy dependency injection and testing | More setup than a simple filter needs |
| Render array | Cacheable output, access checks, structured markup | Integrates with Drupal rendering | Can be verbose for plain text |
| JavaScript | Browser interaction and state changes | Handles behaviour after page load | Poor choice for server-rendered content and SEO |
A well-designed Twig extension keeps the theme layer readable while respecting Drupal’s rendering, security, and caching systems. For a Drupal project serving customers in Brisbane, a government service in Canberra, or an online retailer shipping from Adelaide, that separation makes local formatting rules easier to test and change without scattering PHP logic through templates.