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.
Table of Contents
- 1. Why custom Twig extensions become necessary at all
- 2. Implementing AbstractExtension: the basic structure
- 3. When a filter is the right choice, and when a function is
- 4. Filters with optional arguments and default values
- 5. Writing custom Twig functions
- 6. Testing Twig extensions with PHPUnit
- 7. Performance considerations with complex Twig logic
- 8. Going further: node visitors and custom Twig tags
- 9. Organizing multiple extensions sensibly
- 10. Summary
- 11. FAQ
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