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: September 2026

You need reliable symfony lint:twig command documentation when templates break only in production, or when a custom filter typo slips past code review. Symfony Twig extensions and custom filters keep presentation logic out of templates, but they only stay safe if you lint templates in CI and test extension classes like any other service. This guide documents lint:twig flags and exit codes first, then walks through building filters, functions, and tests on Symfony 8.1 with PHP 8.5 — the same stack I use on enterprise Symfony applications and legal-tech portals in Nepal.

What is the symfony lint:twig command and how do you use it?

The lint:twig console command parses Twig templates and catches syntax errors, unknown filters, and broken extends/includes before deploy. It lives in Symfony's TwigBridge component and ships with every Flex project that uses the Twig bundle. Think of it as a static analyser for your view layer — fast, offline, and scriptable.

Basic usage and paths

From your project root, lint one file or an entire directory:

php bin/console lint:twig templates/base.html.twig
php bin/console lint:twig templates/
php bin/console lint:twig templates/admin/ templates/emails/

Each path can be a file or folder. Symfony walks subdirectories recursively. A clean run prints a green success line per file; failures show the template path, line number, and exception message.

Command options worth knowing

OptionDescriptionTypical use
--format=txtHuman-readable output (default)Local debugging
--format=jsonMachine-readable JSON array of errorsCI pipelines, custom scripts
--format=gitlabGitLab Code Quality report formatGitLab CI merge request annotations
--show-deprecationsLists deprecated Twig features in templatesPre-upgrade audits (Symfony 7 → 8)
--helpFull option list and argument descriptionQuick reference on any machine

Official reference: see the Symfony documentation on linting Twig templates for the latest option list tied to your installed version.

Exit codes and CI integration

lint:twig returns exit code 0 when all templates pass and 1 when any file fails. That makes it ideal as a CI gate. A minimal GitLab CI job might look like this:

lint:twig:
  stage: test
  script:
    - composer install --no-interaction --prefer-dist
    - php bin/console lint:twig templates/ --format=gitlab > gl-code-quality-report.json
  artifacts:
    reports:
      codequality: gl-code-quality-report.json

On projects where I maintain Deployer 7 pipelines, I run lint:twig before PHPUnit. Template errors are cheaper to fix than integration test failures. Pair it with cache warmup in deploy scripts only after lint passes locally. For broader pipeline design, see our notes on CI/CD pipeline setup and GitLab CI for PHP projects.

lint:twig WorkflowEdit Template.html.twiglint:twiglocal + CIPHPUnitextension testsDeployCatches EarlyUnknown filter after renameMissing {% extends %} parentUnclosed {% block %} tagsTypo in custom function nameDeprecated Twig syntaxDoes NOT CatchWrong business logic in filterRuntime null from controllerXSS from is_safe misuseMissing translation keysPerformance-heavy DB calls
Symfony lint:twig command documentation: what the linter validates versus what still needs unit tests

lint:twig validates syntax and symbol resolution at compile time. It does not execute your filter methods with real data. That gap is why extension unit tests remain mandatory. After adding a new custom filter, run lint locally, then write a test that calls the method directly.

How do you create a basic Symfony Twig extension and custom filter?

Symfony Twig extensions are PHP classes extending Twig\Extension\AbstractExtension. The container auto-registers them when autoconfiguration is enabled. If you come from Laravel development, this is closer to a service provider hook than a Blade directive — fully DI-aware and unit-testable.

Extension ClassgetFilters()Container Tagtwig.extensionTwig Template{{ val|format_npr }}Registration Steps1. Kernel compiles tagged twig.extension services2. Twig Environment receives extension instances3. Filters register at compile time4. lint:twig verifies filter names resolve5. Warm cache stores compiled PHP templates
Symfony Twig extension flow: tagged services connect PHP filter methods to template syntax

Create src/Twig/AppExtension.php. Filters transform values; functions generate content; tests return booleans for {% if %} blocks.

<?php
namespace App\Twig;

use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;

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('doc_status_badge', [$this, 'statusBadge'], ['is_safe' => ['html']]),
        ];
    }

    public function getTests(): array
    {
        return [
            new TwigTest('verified_lawyer', [$this, 'isVerifiedLawyer']),
        ];
    }

    public function formatNpr(float|int $amount): string
    {
        return 'Rs ' . number_format($amount, 2);
    }

    public function toBsDate(\DateTimeInterface $date): string
    {
        return $date->format('Y-m-d');
    }

    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, htmlspecialchars(ucfirst($status), ENT_QUOTES, 'UTF-8'));
    }

    public function isVerifiedLawyer(mixed $user): bool
    {
        return is_object($user) && method_exists($user, 'isVerified') && $user->isVerified();
    }
}

Autoconfiguration tags extensions automatically. For explicit control:

# config/services.yaml
services:
    App\Twig\AppExtension:
        tags: ['twig.extension']

After any extension change, clear cache and lint:

php bin/console cache:clear
php bin/console lint:twig templates/

On legal-tech portals such as Notary Nepal and Court Marriage In Nepal, I extract NPR formatting and document status badges into extensions so controllers stay thin. The same pattern applies to client portals with document workflows.

When should you use Twig filters versus functions or tests?

Pick the construct that matches template intent. Misusing types produces awkward syntax and hides bugs that lint:twig cannot spot.

TypeSyntaxUse caseExample
Filter{{ value|name }}Transform existing data; chainable{{ amount|format_npr }}
Function{{ name(args) }}Generate HTML or fetch computed output{{ doc_status_badge(status) }}
Test{% if x is name %}Boolean checks in conditionals{% if user is verified_lawyer %}

Filters chain naturally: {{ amount|round(2)|format_npr }}. Functions suit multi-argument generation without a primary pipe value. Tests keep conditionals readable — prefer {% if user is verified_lawyer %} over {% if is_verified(user) %}.

A twig custom function belongs where you render partial HTML from several arguments. A filter belongs where you reshape one value. Split concerns instead of cramming logic into one mega-function. For reusable bundles, see building your first Symfony bundle.

Filter, Function, or Test?What does template need?Transform valueUse FILTERGenerate outputUse FUNCTIONIf-check onlyUse TESTNepal ExamplesFilter: {{ fee|format_npr }}Function: {{ court_fee_link(caseId) }}Test: {% if date is public_holiday %}Tool: BS date converter logic in service
Choosing between symfony twig extension types: filter, function, or test for domain formatting

How do you inject services into Twig extensions safely?

Real extensions call converters, routers, and translators. Constructor injection works for lightweight services. Heavy dependencies belong in lazy runtime classes.

<?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'])];
    }

    public function toBsDate(\DateTimeInterface $date, string $format = 'F j, Y'): string
    {
        return $this->converter->convertToBs($date)->format($format);
    }
}

For expensive work — payment tokens, API calls, DB lookups — split into a runtime class:

<?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 renderForm(int $orderId): string
    {
        $token = $this->gateway->createToken($orderId);
        return sprintf('<form data-token="%s"></form>', htmlspecialchars($token, ENT_QUOTES, 'UTF-8'));
    }
}
services:
    App\Twig\Runtime\PaymentFormRuntime:
        tags:
            - { name: twig.runtime }

Rules I follow on production Symfony apps:

  • Keep extensions stateless — no mutable properties between renders.
  • Avoid injecting EntityManager directly; use a dedicated query service.
  • Pass request-specific data as template variables, not via RequestStack in extensions.
  • Mark runtime services lazy when they touch HTTP clients or databases.

Date conversion for Nepali apps often mirrors patterns in Bikram Sambat calendar handling and Devanagari Unicode processing. Centralise that logic in a service; keep the Twig layer as a thin wrapper. For architecture context, read hexagonal architecture with Symfony.

How do you handle HTML safety and output escaping in custom filters?

Twig escapes output by default. The is_safe option disables that layer for trusted HTML. Use it only when you escape every dynamic fragment yourself.

new TwigFunction('user_avatar', [$this, 'avatar'], ['is_safe' => ['html']]);

public function avatar(object $user, int $size = 40): string
{
    $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
    );
}

is_safe does not sanitise input. It tells Twig not to double-escape your return string. On eCommerce projects and portals with user-generated content, unescaped case titles or review text in badge helpers have caused stored XSS. Split filters that return plain text from functions that return HTML. Review escaping with the same rigour described in XSS prevention patterns — the Twig rules differ, but the threat model does not.

How do you test Symfony Twig extensions with PHPUnit?

Extensions are plain PHP classes. Test methods directly, then verify registration through Twig rendering. lint:twig catches missing symbols; tests catch wrong output.

<?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 testFormatNpr(): void
    {
        $this->assertSame('Rs 1,234.50', $this->extension->formatNpr(1234.5));
    }

    public function testStatusBadgeEscapesInput(): void
    {
        $html = $this->extension->statusBadge('<script>alert(1)</script>');
        $this->assertStringNotContainsString('<script>', $html);
    }
}

Integration test with the container:

<?php
namespace App\Tests\Twig;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class AppExtensionIntegrationTest extends KernelTestCase
{
    public function testFilterRegistered(): 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));
    }
}
Unit TestsMock servicesAssert return valueslint:twigSyntax + symbolsCI gateIntegrationRender templateVerify registrationCI Pipeline Order1. php bin/console lint:twig templates/2. vendor/bin/phpstan analyse src/Twig3. php bin/phpunit tests/Twig/4. Deploy via Deployer or GitLab CI
Test Symfony Twig extensions with unit tests, lint:twig, and container integration checks

Run php bin/phpunit tests/Twig/ on every push. Block merges when lint or tests fail — the same standard I apply on sister sites sharing Deployer 7 pipelines. Deeper PHPUnit setup is covered in Symfony test suite configuration. Static analysis with PHPStan catches type errors in extension methods that templates mask until runtime.

Before production deploy, follow a short checklist:

  1. Run lint:twig with --show-deprecations after Symfony upgrades.
  2. Unit-test every public filter, function, and test method.
  3. Integration-test at least one template per custom symbol.
  4. Audit every is_safe function for manual escaping.
  5. Warm cache on staging and spot-check pages that use new extensions.

For deployment steps on Ubuntu VPS hosts, see Symfony deployment on Ubuntu. If you need custom Symfony development with domain-specific Twig layers, that is a common deliverable on portals I maintain.

Key Takeaways

  • Run php bin/console lint:twig templates/ locally and in CI; exit code 1 means fix before merge.
  • Extend AbstractExtension, tag with twig.extension, and lint after every rename.
  • Use filters to transform values, functions to generate output, tests for boolean {% if %} checks.
  • Inject lightweight services via constructor; offload heavy work to lazy twig.runtime classes.
  • Treat is_safe as a security contract — escape all dynamic HTML yourself.
  • Combine lint, PHPStan, and PHPUnit; lint alone never validates business logic inside filters.

People Also Ask

Does lint:twig check custom filters and functions?

Yes, at compile time. If a template calls {{ amount|format_npr }} and the filter is not registered, lint:twig reports an unknown filter error. It does not execute your PHP method with real arguments, so logic bugs still need unit tests.

What is the difference between symfony twig extension and a Twig extension in plain PHP?

A Symfony Twig extension is a service tagged twig.extension and wired through the DI container. Plain PHP projects manually call $twig->addExtension(). Symfony autoconfiguration and compiler passes handle registration; you focus on the class itself.

How do I create a twig custom function that returns HTML?

Register a TwigFunction pointing to your method and set ['is_safe' => ['html']]. Escape every dynamic value inside the method with htmlspecialchars(). Never mark user input as safe without encoding.

Can I lint Twig templates outside a Symfony project?

The lint:twig command requires Symfony's TwigBridge and FrameworkBundle. Standalone Twig projects use twigcs or similar linters. Inside Symfony, always prefer the built-in command for consistent exit codes and CI integration.

Ship safer templates with lint:twig and tested extensions

Reliable symfony lint:twig command documentation is the fastest way to stop template typos reaching production. Pair it with focused Symfony Twig extensions — filters for NPR and BS dates, functions for badges, tests for role checks — and a CI gate that runs lint before PHPUnit. That stack keeps views declarative while business rules stay in testable PHP services. Need help structuring extensions for a legal-tech portal or eCommerce build? Contact us about your Symfony project, or reach out directly to discuss scope. Browse the portfolio for shipped examples, or explore the regex tester when building validation filters for template helpers.

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

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: