Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Symfony Twig Extensions and Custom Filters

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.

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.

Extension Classextends AbstractExtensiongetFilters() / getFunctions()Returns TwigFilter[] arrayService Containerservices.yamltags: [twig.extension]Auto-registers at compileTwig Template{{ amount|format_npr }}{{ doc|status_badge }}Calls extension methodRegistration Flow1. Kernel boots → Container compiles all tagged services2. Twig Environment receives extension instances via compiler pass3. Extension registers filters/functions into Twig runtime4. Template rendering resolves {{ filter }} to callable method5. Output escaped unless marked safe (is_safe option)6. Cache warmed: compiled templates reference extension by class name
Symfony Twig extension registration flow: service tagging connects PHP classes to template rendering

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.

TypeSyntaxUse CaseExample
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.

AppExtension__construct(NepaliDateConverter $converter,RouterInterface $router)Service ContainerResolves dependenciesInjects at instantiationShared instance per requestLazy Runtime ExtensionOnly loaded when filter calledAvoids boot cost on every pageUse for heavy services (DB, API)Injection Patterns✓ Constructor injection: preferred for lightweight services (config, router, translator)✓ Lazy runtime: use TwigRuntimeExtensionInterface for expensive services✗ Avoid injecting EntityManager directly — causes circular deps with entity listeners✗ Never inject RequestStack in extensions — use function arguments instead✓ Tag runtime extensions with twig.runtime + lazy attribute✓ Keep extensions stateless — no mutable properties between renders✓ Test with mocked dependencies — extensions are plain PHP classes
Dependency injection patterns for Symfony Twig extensions: constructor vs lazy runtime loading

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.

Unit TestDirect method invocationMock dependenciesFast, isolated, deterministicTests: formatNpr(), toBsDate()Integration TestRender actual Twig templateVerify filter registrationCatches tagging/config errorsTests: {{ amount|format_npr }}Edge Case CoverageNull / empty / boundary valuesUnicode / special charactersXSS payload rejectionLocale / timezone variationsTest Checklist✓ Unit test each public method with mocked dependencies✓ Integration test verifies filter appears in rendered output✓ Assert HTML output contains expected attributes and escaped content✓ Test null/empty inputs don’t throw exceptions✓ Verify is_safe functions don’t leak unescaped user input✓ Run tests in CI on every push — extensions break silently on upgrade
Testing strategy for Symfony Twig extensions: unit isolation plus integration verification

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.

Frequently Asked Questions

A PHP class implementing ExtensionInterface that registers custom filters, functions, tests, or globals for use inside Twig templates without modifying core template logic.

Create a service class extending AbstractExtension, define a TwigFilter in getFilters(), map it to a public method, and tag the service with twig.extension in your services.yaml configuration file.

Use functions for generating content or performing actions requiring multiple arguments; use filters for transforming existing values passed through the pipe operator in templates.

Inject services via constructor injection like any standard Symfony service. Define your extension as a service in config/services.yaml with autowiring enabled, then type-hint required dependencies such as EntityManagerInterface or a custom helper service in the constructor. Avoid using global state or static methods. This keeps extensions testable and follows Symfony dependency injection best practices established since version 4 and maintained through Symfony 7.x releases currently stable in 2026.

Yes, compiled templates cache filter resolution. The extension itself loads once per request via the container. Clearing cache with php bin/console cache:clear regenerates compiled templates if you modify filter signatures or add new extensions. In my experience deploying Symfony apps on Ubuntu servers with PHP-FPM 8.3, forgetting to clear OPcache after deployment causes stale filter behavior even when application cache is cleared. Always restart PHP-FPM or invalidate OPcache during zero-downtime deploys using Deployer 7.

Yes, inject RequestStack via constructor and call getCurrentRequest() within your filter method. Never store the request as a class property because extensions are shared services and requests change between calls. On legal-tech portals I have built where filters format dates based on user timezone from session data, this pattern works reliably. Always check for null returns from getCurrentRequest() since console commands and queue workers lack HTTP context and will throw errors without defensive checks.

Use conditional service loading in config/packages/dev/twig_extensions.yaml or apply environment-specific tags. Alternatively, check the kernel.environment parameter inside getFilters() and return an empty array in production. I have used this approach on client projects where debug-only formatting helpers exposed sensitive data structures during development. Remember that environment checks add minor overhead per request, so prefer separate configuration files over runtime conditionals for extensions that should never load outside development or staging environments.

Filters transform values and return modified output using the pipe syntax. Tests evaluate conditions and return boolean results used in if statements with the is operator. For example, |uppercase transforms text while is even evaluates numerics. On eCommerce projects like Nepal Gift Card, I use custom tests to validate order states in templates without embedding complex PHP conditionals. Tests improve template readability when business logic involves repeated validation patterns that would otherwise clutter markup with verbose comparisons.

Instantiate the extension class directly in PHPUnit, call getFilters() to retrieve definitions, then invoke the mapped callable with test inputs. Mock injected dependencies using PHPUnit createMock(). For integration testing, use Symfony KernelTestCase to boot the container and verify the extension loads correctly. In my experience maintaining Symfony applications since 2010, testing filter methods in isolation catches edge cases faster than rendering full templates. Always test null inputs, unexpected types, and boundary values since templates rarely enforce strict typing before passing data to filters.

Yes, if filters output unescaped user input or execute unsafe operations. Always mark filters returning HTML with is_safe => ['html'] only after sanitizing content. Never pass raw user data to shell commands or database queries inside filters. On legal document portals I have developed, I enforce server-side validation before any filter processes sensitive content. Treat extension methods like controller actions: validate inputs, escape outputs, and assume template authors may misuse them. Security reviews must include custom extensions alongside controllers and forms.

Manually add the twig.extension tag in services.yaml under your service definition. Set autoconfigure: false if you need explicit control. Specify the extension class and any constructor arguments explicitly. This approach is necessary when integrating third-party libraries that provide extensions but lack proper Symfony bundle integration. I have encountered this on legacy Magento-to-Symfony migrations where older extensions required manual wiring. Verify registration by running php bin/console debug:twig to list all loaded extensions and confirm yours appears in the output.

Common causes include missing twig.extension tag, incorrect service ID, uncleared cache, or namespace mismatches. Run php bin/console debug:twig to verify registration. Check that getFilters() returns properly constructed TwigFilter instances with correct callable references. On production deployments using Deployer 7, I have seen stale symlinks point to old release directories containing outdated extension code. Ensure shared storage paths and OPcache invalidation are configured correctly. Also verify PHP version compatibility since Symfony 7 requires PHP 8.2 minimum and some filter syntax changed between versions.

Implement GlobalsInterface in your extension and define getGlobals() returning an associative array. Values become available as top-level variables in every template without explicit passing. Use sparingly since globals increase memory usage and coupling. On directory sites like Lawyers Pokhara, I expose site configuration and navigation data this way. Prefer injecting specific data via controllers for page-specific content. Globals suit truly universal data like site name, current year, or feature flags. Always document global variables since they are implicit dependencies invisible in template signatures.

Minimal when properly implemented. Extensions load once per request via the container. Filter execution cost depends entirely on method complexity. Avoid database queries, API calls, or heavy computation inside filters called repeatedly in loops. Cache expensive results using Symfony Cache component or Redis. On high-traffic eCommerce sites, I have seen poorly written filters cause N+1 query problems identical to controller issues. Profile templates with Symfony Profiler toolbar to identify slow filters. Move business logic to services and keep filters as thin presentation adapters whenever possible.

Call setDeprecation() on the TwigFilter instance with package name, version, and alternative message. Symfony logs deprecation notices in dev environment and profiler. Maintain backward compatibility for at least one major version before removal. Document migration path clearly in changelog. On long-lived legal-tech platforms, I schedule deprecations alongside client communication cycles since template updates require coordination. Never remove filters abruptly from production systems. Monitor deprecation logs to track usage before final removal. This disciplined approach prevents breaking live sites during framework upgrades.

Share this article

Quick Contact Options
Choose how you want to connect me: