Symfony Twig: Writing Custom Extensions, Filters, and Functions
AI generated
SF
{ }
Twig Templating
Twig in Symfony: Writing Custom Extensions, Filters, and Functions
AbstractExtension, filter vs. function, and a price formatting example

How to write custom Twig filters and functions in Symfony using AbstractExtension, when each concept is the right choice, and what performance aspects matter with complex Twig logic.

14 min read Twig 3 Symfony 7

1. Why custom Twig extensions become necessary at all

Twig ships with a large collection of built in filters like upper, date, or number_format that are perfectly sufficient for many standard cases. But as soon as project specific formatting rules come into play, such as a price that needs to be formatted differently depending on the country, or recurring logic for rendering status badges, the built in filters quickly reach their limits.

The naive solution would be to rebuild this logic directly in the template using Twig control structures like {% if %} and {% set %}, but that quickly makes templates unreadable and duplicates logic wherever the same formatting is needed elsewhere. A custom Twig extension solves exactly this problem: PHP code handles the actual logic, and a single, clearly named filter or function is exposed to the template.

2. Implementing AbstractExtension: the basic structure

The entry point for any custom Twig extension is a class extending Twig\Extension\AbstractExtension and overriding at least one of the two methods getFilters() or getFunctions(). Both methods return an array of TwigFilter or TwigFunction objects respectively, each connecting a name to a PHP callable, usually a method on the same class.

Symfony automatically recognizes such a class as soon as it is registered as a service and tagged with twig.extension, which already happens by default with autoconfigure enabled, as long as the class implements AbstractExtension. Manual registration in services.yaml is therefore usually not necessary, which makes getting started noticeably simpler.

3. When a filter is the right choice, and when a function is

The rule of thumb is simple: a filter transforms an already existing value, so it is used with pipe syntax like {{ price|money('EUR') }} and expects the value to be transformed as its first argument. A function, on the other hand, creates or computes a completely new value, often without an obvious input, and is called like {{ current_user_badge() }} without pipe notation.

A price formatting filter is therefore a classic filter case, since an existing raw amount gets transformed. A function that, for example, loads the number of unread notifications from the database is clearly a function instead, since it does not transform an input value but determines a new value from scratch. The example below shows a complete price formatting filter for Symfony 7.


<?php

declare(strict_types=1);

namespace App\Twig;

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

final class MoneyExtension extends AbstractExtension
{
    public function __construct(
        private readonly \NumberFormatter $formatter = new \NumberFormatter('de_DE', \NumberFormatter::CURRENCY),
    ) {
    }

    public function getFilters(): array
    {
        return [
            new TwigFilter('money', $this->formatMoney(...)),
        ];
    }

    public function formatMoney(int $amountInCents, string $currency = 'EUR'): string
    {
        return $this->formatter->formatCurrency($amountInCents / 100, $currency);
    }
}

4. Filters with optional arguments and default values

As shown in the example above, custom filter methods support regular PHP default parameter values, so both {{ price|money }} and {{ price|money('USD') }} work in the template, depending on whether a different currency is needed. This flexibility makes a filter significantly more reusable in practice, without needing a separate filter definition for every variation.

TwigFilter also supports its own options such as is_safe, to mark that the return value already contains safe HTML and should not be automatically escaped. That matters when a filter itself generates HTML markup, for example a status badge with a colored background, which would otherwise be rendered as plain text by Twig's automatic escaping without this marker.

5. Writing custom Twig functions

A Twig function is registered similarly through getFunctions() and a TwigFunction object. A typical example is a function is_feature_enabled('checkout_v2') that allows a feature flag check directly in the template, without the controller having to explicitly pass that information to the template beforehand. Such functions are especially handy for cross cutting concerns needed across many different templates.

It is important to use functions thoughtfully. If a function becomes too powerful, for example by directly running database queries, business logic silently shifts out of controllers and services into the presentation layer, which makes testing harder and blurs the separation of concerns. A function should therefore ideally only read from data that is already prepared, cached, or very cheap to retrieve.

6. Testing Twig extensions with PHPUnit

Since a Twig extension is ultimately just a PHP service, it can be tested completely without a Twig kernel or template rendering. For the MoneyExtension filter, a simple PHPUnit test calling formatMoney() directly and checking the result against an expected formatted string is enough, entirely bypassing a rendered template.

For cases where the interaction with real Twig rendering actually needs to be verified, for example to make sure the pipe syntax resolves correctly in a template, an integration test with a minimal Twig\Environment instance works well, registering the extension via addExtension() and rendering a small test string.

7. Performance considerations with complex Twig logic

Twig templates run on every request unless result caching kicks in, which means expensive operations inside a filter or function, such as an extra database query per loop iteration in a product list, can quickly add up to a noticeable performance problem, especially with lists containing hundreds of entries.

In such cases it is almost always better to load the required data efficiently in the controller or a view model beforehand (for example via a join or a single batch query) and pass it to the template as an already prepared array, instead of calling the Twig function separately for every row. Ideally, the Twig function or filter should only handle pure, cheap formatting, not data retrieval.

8. Going further: node visitors and custom Twig tags

For very advanced use cases, AbstractExtension also offers getNodeVisitors() alongside getFilters() and getFunctions(), and through a separate TokenParser interface the ability to define entirely custom Twig tags like {% cache %}. This is significantly more effort than a filter or function and, in practice, actually needed only in the rarest of cases.

For the vast majority of projects, filters and functions are completely sufficient to cleanly encapsulate recurring formatting and presentation logic. Before building a custom tag, it is almost always worth asking whether the same requirement can be solved just as well with a combination of existing filters, functions, and Twig's built in control structures.

9. Organizing multiple extensions sensibly

As a project grows, several Twig extensions quickly accumulate, for example for price formatting, date rendering, user badges, and feature flags. Instead of bundling all filters and functions into a single massive class, it is recommended to create thematically separated extension classes, such as MoneyExtension, DateExtension, and FeatureFlagExtension, each with a clearly defined responsibility.

This separation not only makes it easier to test each extension in isolation, it also makes it easier for new team members to actually find the existing filters and functions in the first place, since the class name already reveals what the extension is responsible for, instead of searching through a single AppExtension with dozens of unrelated methods.

Concept Called in template as Typical purpose Key class
Filter {{ value|filtername(arg) }} Transforms an existing value TwigFilter
Function {{ functionname(arg) }} Creates/computes a new value TwigFunction
Node visitor / tag {% custom_tag %}...{% endcustom_tag %} Entirely new template syntax TokenParser / AbstractExtension
is_safe option In TwigFilter/TwigFunction constructor Prevents double HTML escaping TwigFilter(['is_safe' => ['html']])

Mironsoft

Symfony architecture, clean domain logic, and legacy modernization

Symfony applications that stay maintainable two years down the line?

We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.

Architecture Review

Checking bundle structure, dependency injection, and service abstractions for maintainability.

Legacy Modernization

Incrementally migrating outdated Symfony versions without a full rewrite.

Testing and Quality Assurance

Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.

10. Summary

Twig Extensions

AbstractExtension

Base class, getFilters()/getFunctions() supply the definitions

Filter vs. function

A filter transforms a value, a function creates a new one

Performance

Expensive data retrieval belongs in the controller, not the template

Organization

Thematically separated extension classes instead of one giant AppExtension

11. FAQ: Twig Extensions

1Do I need to manually register a Twig extension in services.yaml?
Usually not. Symfony automatically recognizes any class implementing AbstractExtension thanks to autoconfigure and registers it with the twig.extension tag as soon as it is available as a service.
2How do I decide whether I need a filter or a function?
If the logic transforms an already existing value, a filter is the right choice. If a completely new value is created or computed without an obvious input value, a function fits better.
3Can a filter accept multiple arguments?
Yes, every argument after the transformed value is passed as a regular PHP parameter to the callback method, including optional parameters with default values.
4What does the is_safe option on a filter mean?
It marks that the return value already contains safe HTML and should be excluded from Twig's automatic escaping, which is needed for filters that generate HTML markup themselves.
5Should Twig functions be allowed to run database queries?
Not directly on every call inside a loop if it can be avoided, since that quickly leads to performance problems. It is better to load data efficiently ahead of time in the controller.
6How do I test a custom Twig extension?
Since an extension is just a regular PHP service, its methods can be tested directly with PHPUnit, without any Twig rendering. A minimal Twig\Environment instance is enough for integration tests.
7When is a custom Twig tag worth building instead of a filter?
Only in very rare cases, when a completely new template syntax is needed that cannot be expressed with existing filters, functions, and control structures.
8How do I organize multiple Twig extensions in a larger project?
Best done thematically separated into their own classes like MoneyExtension or DateExtension, instead of bundling all filters and functions into one large AppExtension.
9Can a Twig function use services through dependency injection?
Yes, since an extension is a regular Symfony service, any service can be injected normally through the constructor, such as a feature flag service or a repository.
10What is the most common mistake when writing custom Twig filters?
Running expensive operations like database queries directly inside the filter, even though the filter is often called multiple times per template render, leading to unnecessarily many database roundtrips.