
August 12, 2026
11 min read
Table of Contents
By Kokil Thapa | Last reviewed: August 2026
Symfony Twig extensions and custom filters let you encapsulate reusable presentation logic outside your templates, keeping views clean and business rules testable. When building legal-tech portals or eCommerce platforms on Symfony 7, I frequently need domain-specific formatting — Bikram Sambat date conversion, NPR currency display, or document status badges — that core Twig cannot handle. This guide shows exactly how to create, register, and test these extensions using current PHP 8.4 and Symfony 7.x conventions.
AbstractExtension that register filters, functions, or tests via the container. Create an extension class, define methods returning TwigFilter or TwigFunction objects, tag the service with twig.extension, and call them directly in templates like {{ value|my_filter }}.How do you create a basic Symfony Twig extension and custom filter?
The foundation of any custom template logic in Symfony is the extension class. Unlike standalone Twig setups where you manually add extensions to the environment, Symfony’s dependency injection container handles registration automatically when services are tagged correctly. For developers transitioning from Laravel development, this is analogous to Blade directives but fully integrated into the service container and testable as isolated units.
Create your extension class in src/Twig/AppExtension.php. The class must extend Twig\Extension\AbstractExtension and implement either getFilters() or getFunctions() depending on what you need. Filters transform existing values ({{ value|filter }}), while functions generate new content ({{ function(args) }}).
<?php
// src/Twig/AppExtension.php
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class AppExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('format_npr', [$this, 'formatNpr']),
new TwigFilter('bs_date', [$this, 'toBsDate']),
];
}
public function getFunctions(): array
{
return [
new TwigFunction('document_status_badge', [$this, 'statusBadge'], ['is_safe' => ['html']]),
];
}
public function formatNpr(float|int $amount): string
{
return 'Rs ' . number_format($amount, 2);
}
public function toBsDate(\DateTimeInterface $date): string
{
// Convert AD to BS using a library like nepali-date-converter
// Returns formatted Bikram Sambat date string
return \NepaliDate::fromAd($date)->format('F j, Y BS');
}
public function statusBadge(string $status): string
{
$classes = match ($status) {
'approved' => 'badge bg-success',
'pending' => 'badge bg-warning text-dark',
'rejected' => 'badge bg-danger',
default => 'badge bg-secondary',
};
return sprintf('<span class="%s">%s</span>', $classes, ucfirst($status));
}
} Register the extension as a service. With Symfony 7’s autoconfiguration enabled (default in config/services.yaml), any class extending AbstractExtension is automatically tagged. If autoconfiguration is disabled or you need explicit control, add the tag manually:
# config/services.yaml
services:
App\Twig\AppExtension:
tags: ['twig.extension'] Clear the cache after creating or modifying extensions. Twig compiles templates to PHP classes, and stale cache will not recognize new filters:
php bin/console cache:clear
php bin/console twig:lint templates/ When should you use Twig filters versus functions or tests?
Choosing the right extension type prevents awkward template syntax and maintains semantic clarity. In my experience building Nepal legal-tech portals like Court Marriage In Nepal and Notary Nepal, misusing these types leads to templates that are hard to read and maintain. The distinction matters because each serves a different purpose in the rendering pipeline.
| Type | Syntax | Use Case | Example |
|---|---|---|---|
| Filter | {{ value|name }} | Transform existing data; chainable; always receives input as first argument | {{ amount|format_npr }}, {{ date|bs_date }} |
| Function | {{ name(args) }} | Generate content; accept multiple arguments; no required input value | {{ document_status_badge(status) }}, {{ render_payment_form(order) }} |
| Test | {% if value is name %} | Boolean checks in conditionals; returns true/false only | {% if user is verified_lawyer %}, {% if date is holiday %} |
Filters are for transformation. If you have a value and need to change its representation, use a filter. They are chainable ({{ amount|round(2)|format_npr }}) and always receive the left-hand value as the first parameter. Functions are for generation. When you need to produce HTML, fetch computed data, or accept multiple independent arguments without a primary input, use a function. Tests are strictly boolean predicates for {% if %} blocks. Never use a filter or function where a test belongs — it forces verbose comparisons like {% if is_verified(user) == true %} instead of the idiomatic {% if user is verified %}.
A common mistake I see in code reviews is using functions for simple transformations because the developer didn’t realize filters exist. Conversely, complex multi-argument operations crammed into filters become unreadable chains. Match the construct to the intent, and your templates stay declarative.
How do you inject services into Twig extensions safely?
Real-world extensions rarely operate in isolation. Formatting NPR amounts might require reading locale settings from configuration. Converting dates needs a converter service. Generating payment forms requires accessing the router or security token. Symfony’s dependency injection makes this straightforward, but there are pitfalls around circular dependencies and performance that catch developers unfamiliar with Twig’s compilation model.
For lightweight services like configuration, router, or translator, use standard constructor injection:
<?php
// src/Twig/NepaliDateExtension.php
namespace App\Twig;
use App\Service\NepaliDateConverter;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class NepaliDateExtension extends AbstractExtension
{
public function __construct(
private readonly NepaliDateConverter $converter,
) {}
public function getFilters(): array
{
return [
new TwigFilter('bs_date', [$this, 'toBsDate']),
new TwigFilter('bs_datetime', [$this, 'toBsDateTime']),
];
}
public function toBsDate(\DateTimeInterface $date, string $format = 'F j, Y'): string
{
return $this->converter->convertToBs($date)->format($format);
}
public function toBsDateTime(\DateTimeInterface $date): string
{
return $this->converter->convertToBs($date)->format('F j, Y g:i A');
}
} For expensive services (database queries, external API clients, heavy computation), use lazy runtime extensions. These are only instantiated when the filter or function is actually called during rendering, avoiding unnecessary overhead on pages that don’t use them:
<?php
// src/Twig/Runtime/PaymentFormRuntime.php
namespace App\Twig\Runtime;
use App\Service\PaymentGateway;
use Twig\Extension\RuntimeExtensionInterface;
class PaymentFormRuntime implements RuntimeExtensionInterface
{
public function __construct(
private readonly PaymentGateway $gateway,
) {}
public function renderPaymentForm(int $orderId): string
{
// Expensive: calls gateway API to generate token
$token = $this->gateway->createToken($orderId);
return sprintf('<form data-token="%s">...</form>', htmlspecialchars($token));
}
}
// src/Twig/PaymentExtension.php
namespace App\Twig;
use App\Twig\Runtime\PaymentFormRuntime;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class PaymentExtension extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('payment_form', [PaymentFormRuntime::class, 'renderPaymentForm']),
];
}
} Tag the runtime service explicitly in services.yaml:
services:
App\Twig\Runtime\PaymentFormRuntime:
tags:
- { name: twig.runtime, lazy: true } This pattern is critical for performance on high-traffic sites. On one eCommerce project, moving payment form generation to a lazy runtime reduced average page render time by 40ms because most pages never invoke that function. Always prefer lazy loading for anything touching databases, HTTP clients, or filesystem operations.
How do you handle HTML safety and output escaping in custom filters?
Twig auto-escapes all output by default, which is correct for security. But some extensions legitimately produce HTML — status badges, formatted links, embedded widgets. Marking output as safe incorrectly creates XSS vulnerabilities; forgetting to mark it produces double-escaped garbage. Getting this right is non-negotiable for any eCommerce or portal project handling user-generated content.
The is_safe option tells Twig not to escape the return value. Use it only when you have complete control over the generated HTML and have sanitized all dynamic inputs:
new TwigFunction('user_avatar', [$this, 'avatar'], ['is_safe' => ['html']])
public function avatar(User $user, int $size = 40): string
{
// Safe: URL is generated from known-safe base path + hashed filename
// Alt text is escaped explicitly
$url = '/uploads/avatars/' . $user->getAvatarHash() . '.jpg';
$alt = htmlspecialchars($user->getDisplayName(), ENT_QUOTES, 'UTF-8');
return sprintf(
'<img src="%s" alt="%s" width="%d" height="%d" class="rounded-circle">',
htmlspecialchars($url, ENT_QUOTES, 'UTF-8'),
$alt,
$size,
$size
);
} Never pass raw user input into HTML-generating functions without escaping. Even with is_safe, you must manually escape every dynamic attribute value. The is_safe flag disables Twig’s automatic escaping layer — it does not make your code magically secure. I’ve audited legal-tech portals where unescaped case numbers in badge components created stored XSS vectors. Always treat is_safe as "I promise I’ve escaped everything myself."
For filters that sometimes return HTML and sometimes return plain text, split them into two separate filters rather than conditional safety. A single filter with variable output types defeats static analysis and confuses template authors about what’s safe.
How do you test Symfony Twig extensions with PHPUnit?
Untested template logic is technical debt. Extensions are plain PHP classes with no framework coupling beyond their interface, making them trivially unit-testable. On projects like Mijar Law Associates where document status rendering affects client-facing portals, I require extension tests before merge. Testing catches escaping bugs, edge cases in date conversion, and regressions during Symfony upgrades.
Start with direct unit tests. Instantiate the extension with mocked dependencies and assert return values:
<?php
// tests/Twig/AppExtensionTest.php
namespace App\Tests\Twig;
use App\Twig\AppExtension;
use PHPUnit\Framework\TestCase;
class AppExtensionTest extends TestCase
{
private AppExtension $extension;
protected function setUp(): void
{
$this->extension = new AppExtension();
}
public function testFormatNprWithDecimal(): void
{
$this->assertSame('Rs 1,234.50', $this->extension->formatNpr(1234.5));
}
public function testFormatNprWithInteger(): void
{
$this->assertSame('Rs 5,000.00', $this->extension->formatNpr(5000));
}
public function testStatusBadgeApproved(): void
{
$html = $this->extension->statusBadge('approved');
$this->assertStringContainsString('bg-success', $html);
$this->assertStringContainsString('Approved', $html);
}
public function testStatusBadgeEscapesUnknownStatus(): void
{
$html = $this->extension->statusBadge('<script>alert(1)</script>');
$this->assertStringNotContainsString('<script>', $html);
$this->assertStringContainsString('badge bg-secondary', $html);
}
} Add integration tests to verify the extension is properly registered and works within actual Twig rendering. Use Symfony’s KernelTestCase or the dedicated TwigTestCase if available:
<?php
// tests/Twig/AppExtensionIntegrationTest.php
namespace App\Tests\Twig;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class AppExtensionIntegrationTest extends KernelTestCase
{
public function testFormatNprFilterIsRegistered(): void
{
self::bootKernel();
$twig = self::getContainer()->get('twig');
$result = $twig->createTemplate('{{ amount|format_npr }}')
->render(['amount' => 2500]);
$this->assertSame('Rs 2,500.00', trim($result));
}
public function testStatusBadgeRendersSafeHtml(): void
{
self::bootKernel();
$twig = self::getContainer()->get('twig');
$result = $twig->createTemplate('{{ status|document_status_badge }}')
->render(['status' => 'pending']);
$this->assertStringContainsString('bg-warning', $result);
$this->assertStringContainsString('Pending', $result);
}
} Run tests with php bin/phpunit tests/Twig/. Integrate this into your GitLab CI pipeline alongside twig:lint to catch both logic errors and registration failures before deployment. For teams working on CI/CD pipelines, failing extension tests should block merges just like controller or repository tests.
Practical Next Steps for Production Twig Extensions
Symfony Twig extensions and custom filters are among the most maintainable ways to keep template logic testable and reusable. Start by identifying repeated formatting patterns in your templates — currency, dates, status indicators, document links — and extract them into focused extension classes. Use constructor injection for lightweight services, lazy runtime for expensive ones, and always write unit tests before considering the work complete. If you’re building a Symfony application and need help structuring extensions for your specific domain, reach out to discuss your project.

