
August 12, 2026
12 min read
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.
php bin/console lint:twig templates/ to validate Twig syntax and renderability; use --format=json in CI and --show-deprecations before upgrades. Register Symfony Twig extensions by extending AbstractExtension, tagging services with twig.extension, then call {{ value|my_filter }} in templates.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
| Option | Description | Typical use |
|---|---|---|
--format=txt | Human-readable output (default) | Local debugging |
--format=json | Machine-readable JSON array of errors | CI pipelines, custom scripts |
--format=gitlab | GitLab Code Quality report format | GitLab CI merge request annotations |
--show-deprecations | Lists deprecated Twig features in templates | Pre-upgrade audits (Symfony 7 → 8) |
--help | Full option list and argument description | Quick 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 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.
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.
| Type | Syntax | Use case | Example |
|---|---|---|---|
| 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.
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
EntityManagerdirectly; use a dedicated query service. - Pass request-specific data as template variables, not via
RequestStackin 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));
}
} 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:
- Run
lint:twigwith--show-deprecationsafter Symfony upgrades. - Unit-test every public filter, function, and test method.
- Integration-test at least one template per custom symbol.
- Audit every
is_safefunction for manual escaping. - 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 withtwig.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.runtimeclasses. - Treat
is_safeas 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
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.

