Drupalwoo ☉

Build a custom Twig filter for Drupal string handling

Twig is Drupal’s template layer, so repeated formatting often belongs there rather than in a node template written for one content type. A custom Twig filter for string manipulation in Drupal turns an operation such as au_phone or word_limit into a reusable template function. The result is cleaner markup, consistent output and logic that site builders can maintain without changing core.

Australian content makes a generic filter risky if it assumes North American conventions. Phone numbers may arrive as +61 2 8123 4567, (03) 9876 5432, 0400 123 456 or a 1300 service number. Place names such as Wagga Wagga, Coffs Harbour and Parramatta also contain spaces or repeated syllables, while editorial copy may preserve informal words such as “arvo” or Australian English spellings such as “organisation”. A useful filter therefore needs explicit rules rather than a blind chain of replacements.

The commercial context matters too. Australian Drupal work includes agency websites, council services, universities and some platforms connected with GovCMS, often alongside older multisites and bespoke integrations. Teams in Sydney, Melbourne, Brisbane and Canberra may share components, while privacy-sensitive fields must still align with the Privacy Act 1988 and communications sent under the Spam Act 2003. A reusable filter should format data without changing its meaning.

Scope the operation before writing code

Put the filter in a custom Drupal module, preferably under web/modules/custom, rather than patching Twig or core. A dedicated module gives the extension its own namespace, service definition, tests and release history. It also lets another site adopt the same behaviour without copying template edits into every content type.

Define the contract before writing callbacks. Decide whether the filter accepts only a string, how it handles null or malformed values, whether it preserves the original input, and which external services it may call. For this example, au_phone will normalise Australian phone notation and return trimmed input when it cannot recognise a safe pattern. word_limit will collapse whitespace and append an ellipsis without trying to interpret HTML.

Create these files inside the module:

# web/modules/custom/au_text_tools/au_text_tools.info.yml
name: 'AU Text Tools'
type: module
description: 'Reusable Twig filters for Australian editorial formatting.'
core_version_requirement: '^10 || ^11'

The class namespace must match its path and be visible to Composer. For a simple site-managed module, merge this mapping into the existing PSR-4 entries in the project’s root composer.json. Alternatively, publish the module as a Composer package and require it from the project. Either way, the namespace must resolve before Drupal can instantiate the service.

"autoload": {
  "psr-4": {
    "Drupal\\au_text_tools\\": "web/modules/custom/au_text_tools/src/"
  }
}

Run composer dump-autoload -o and enable the module with drush en au_text_tools -y.

Register the Twig extension

Twig extensions are ordinary Drupal services. Add au_text_tools.services.yml beside the information file:

services:
  au_text_tools.twig_extension:
    class: Drupal\au_text_tools\TwigExtension
    tags:
      - { name: twig.extension }

The extension class declares the names exposed to templates. Create src/TwigExtension.php with this shell:

<?php

namespace Drupal\au_text_tools;

use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;

final class TwigExtension extends AbstractExtension
{
  public function getFilters(): array
  {
    return [
      new TwigFilter('au_phone', [$this, 'formatAuPhone']),
      new TwigFilter('word_limit', [$this, 'wordLimit']),
    ];
  }
}

Each TwigFilter pairs a template name with a callable method. The twig.extension tag tells Drupal’s Twig compiler pass to collect the class and make its filters available. If you want a second Drupal-specific reference while comparing service patterns, the site’s Twig extension guidance offers another place to check the structure. The methods referenced below belong in the same class and must be public.

Implement reliable string handling

Add the phone formatter as a method inside TwigExtension:

public function formatAuPhone(string $value): string
{
  $original = trim($value);
  $digits = preg_replace('/\D+/', '', $original);

  if ($digits === null) {
    return $original;
  }

  if (str_starts_with($digits, '0061')) {
    $digits = substr($digits, 4);
  }
  if (str_starts_with($digits, '61')) {
    $digits = '0' . substr($digits, 2);
  }

  if (preg_match('/^04\d{8}$/', $digits) === 1) {
    return sprintf(
      '%s %s %s',
      substr($digits, 0, 4),
      substr($digits, 4, 3),
      substr($digits, 7, 3)
    );
  }

  if (preg_match('/^0[2378]\d{8}$/', $digits) === 1) {
    return sprintf(
      '%s %s %s',
      substr($digits, 0, 2),
      substr($digits, 2, 4),
      substr($digits, 6, 4)
    );
  }

  if (preg_match('/^(13|18)\d{8}$/', $digits) === 1) {
    return sprintf(
      '%s %s %s',
      substr($digits, 0, 4),
      substr($digits, 4, 3),
      substr($digits, 7, 3)
    );
  }

  return $original;
}

The callback removes punctuation, converts 0061 or 61 to the local 0 prefix and recognises mobile, landline and 1300 or 1800 service-number formats. Returning the trimmed source for unknown input avoids silently turning extensions or incomplete numbers into convincing-looking data. Fixed-width substr() calls are safer than integer casting because they preserve leading zeroes.

A second filter can handle short plain-text summaries:

public function wordLimit(string $value, int $limit = 25): string
{
  $value = preg_replace('/\s+/u', ' ', trim($value));

  if ($value === null) {
    return '';
  }

  $value = trim($value);
  if ($limit < 1) {
    return '';
  }

  $words = preg_split('/\s+/u', $value, -1, PREG_SPLIT_NO_EMPTY);
  if ($words === false || count($words) <= $limit) {
    return $value;
  }

  return implode(' ', array_slice($words, 0, $limit)) . '…';
}

Keep these callbacks limited to deterministic string operations. Drupal’s Twig auto-escaping still protects the final output, so do not mark the filter as safe and do not add |raw; |raw is appropriate only for markup that has already been sanitised. A word limit can split HTML or alter meaning if applied to a rich-text field, so use a plain-text value or a suitable text-formatting pipeline first. If later handling needs transliteration, inject Drupal’s transliteration service through the service definition rather than depending on a platform-dependent iconv() result.

Apply filters in Drupal templates

Once caches are rebuilt, templates can call the filters as pipes:

{% if node.field_office_phone.value %}
  {{ node.field_office_phone.value|au_phone }}
{% endif %}

{{ node.body.value|word_limit(18) }}
{{ '+61 2 8123 4567'|au_phone }}

Adapt the field machine names to the content model. A field’s .value may be a string, but multi-valued fields produce arrays; normalise the value or select one delta before passing it to a filter. Filter order also matters: |trim|word_limit(18) collapses edge whitespace before counting, while |word_limit(18)|trim does not. Twig arguments follow the filter name in parentheses and can contain variables.

Because filters run during rendering, store only source data in entities and derive display values in Twig. This keeps canonical field content suitable for search, APIs and migration, while normal entity updates handle cache invalidation. For expensive transformations, precompute a derived value in a field formatter or preprocess layer instead of repeating work on every render. Twig compilation caches template code, not arbitrary database queries performed inside a filter.

The same design handles Australian editorial cases without making assumptions about spelling or place names. A future slug filter could preserve Wagga Wagga and Parramatta while normalising case and separators, rather than deleting repeated syllables. Filters can also preserve quoted language such as “arvo” or apply approved Australian English conventions without rewriting names, organisations and direct quotations.

Test, cache and maintain the filter

Give the callback unit tests before relying on it:

<?php

namespace Drupal\Tests\au_text_tools\Unit;

use Drupal\Tests\UnitTestCase;
use Drupal\au_text_tools\TwigExtension;

final class TwigExtensionTest extends UnitTestCase
{
  public function testFormatting(): void
  {
    $extension = new TwigExtension();

    self::assertSame(
      '02 8123 4567',
      $extension->formatAuPhone('+61 2 8123 4567')
    );
    self::assertSame(
      '0400 123 456',
      $extension->formatAuPhone('0400 123 456')
    );
    self::assertSame(
      'A short…',
      $extension->wordLimit('A short summary', 2)
    );
  }
}

Put the test in tests/src/Unit/TwigExtensionTest.php and run it through the project’s PHPUnit setup. During deployment, install updated Composer dependencies, regenerate the autoloader and rebuild Drupal’s caches with drush cache:rebuild. PHP opcache settings can also affect whether updated callback code is immediately visible. A missing filter usually indicates an autoloading, namespace or service-tag problem, while an outdated result often points to cached Twig or render data.

Several Drupal patterns can produce similar results:

Approach Best suited to Main benefit Main limitation
Built-in Twig filters Case changes, trimming and simple replacements No PHP or service setup Generic operations only
Custom Twig filter Repeated domain-specific string rules Reusable from every Twig template Requires registration and tests
preprocess() hook Bundle-specific display preparation Convenient access to entity data Tied to a particular implementation
Custom field formatter Values displayed through a structured field Consistent interface and cacheable rendering More Drupal-specific work
Theme function or direct PHP Legacy or markup-heavy cases Maximum flexibility Greater security and maintenance burden

For repeated formatting that is deterministic and presentational, the custom Twig filter is the clearest fit: it stays close to the template, remains reusable across content types and keeps presentation rules out of individual node templates. Built-in filters handle simple edits, while field formatters are better when a value needs structured configuration or independent caching.