| name | twig-conventions |
| description | ACTIVATE when writing or modifying Twig templates, using Twig components (twig:*), handling translations in Twig, debugging translation issues, or deciding whether to create a Twig component. ACTIVATE whenever 'trans_default_domain', 'twig component', 'translation not working', 'create twig component', or 'should I create a component' appears. Covers: trans_default_domain isolation in Twig components (1 translation = inline domain, 2+ translations = hoist trans_default_domain in the component scope), ClockInterface for dates in templates, when to create a Twig component vs use direct HTML. DO NOT use for: Twig syntax basics, Symfony controller rendering, generic CSS questions. |
| version | 1.3 |
Twig Conventions
Critical pitfalls and decision rules for Twig templates and components.
Critical Concepts
When to Create a Twig Component (<twig:*>)
A Twig component is only justified if it encapsulates complex logic — either in the component itself or in its PHP class. Use HTML directly otherwise.
Create a component when:
- Non-trivial rendering logic (multiple conditional branches, props transformations, slots)
- Business logic in the PHP class (calculations, services, computed state)
- Effective reuse in ≥ 2 independent templates with distinct callsites
Don't create a component when:
- It's just a wrapper around an HTML element with a CSS class
- The class is empty and the template is a single tag
{# WRONG — Twig component with no logic #}
<twig:Marker />
{# CORRECT — Direct HTML #}
<span class="marker"></span>
A component without logic adds overhead (YAML registration, PHP file, Twig file) for zero benefit. Raw HTML is more readable, faster to render, and easier to grep.
Decision checklist
Before creating MyComponent.php + MyComponent.html.twig, answer:
- Will the PHP class have non-trivial methods or constructor dependencies? → if no, reconsider
- Will the template have conditionals, loops, or computed values? → if no, reconsider
- Will it be called from ≥ 2 places with different contexts? → if no, reconsider
If all three answers are "no", write inline HTML + class. You can always extract to a component later when the need emerges.
Translation Domains in Twig Components
IMPORTANT: Twig components (<twig:*>) are isolated and do NOT inherit trans_default_domain from the parent template.
The Problem
When using trans_default_domain in a parent template, child content passed to a Twig component does NOT inherit this domain:
{# Parent template #}
{% trans_default_domain 'my_domain' %}
<twig:Button>
{{ 'my_key'|trans }} {# WRONG: Will look in 'messages' domain, not 'my_domain' #}
</twig:Button>
The Solution
There are two acceptable ways to set the domain inside a Twig component, depending on how many translations the component content holds.
Rule: 1 translation → inline domain:
When there is a single |trans call inside the component content, pass the domain inline. No need to add a setup line for one call.
<twig:Alert>
<twig:block name="content">{{ 'warning'|trans(domain: 'my_domain') }}</twig:block>
</twig:Alert>
Rule: 2+ translations → hoist {% trans_default_domain %} once
When the component content contains 2+ |trans calls, declare {% trans_default_domain %} once at the top of the content rather than repeating (domain: '...') on every line. Repetition is a refactor liability — change the domain once and you've shifted N spots.
<twig:Table>
{% trans_default_domain 'my_domain' %}
<thead>
<tr>
<th>{{ 'col.name'|trans }}</th>
<th>{{ 'col.type'|trans }}</th>
<th>{{ 'col.isin'|trans }}</th>
<th>{{ 'col.sri'|trans }}</th>
<th>{{ 'col.allocation'|trans }}</th>
</tr>
</thead>
{# ... #}
</twig:Table>
The trans_default_domain declared inside the component's content scope applies within that isolated scope.
Why This Happens
Twig components process their content in isolation. The trans_default_domain tag sets a variable in the current template scope, but component content is evaluated in a separate context where the parent's variable is not accessible. Re-declaring trans_default_domain inside the component content sets it in that inner scope.
Full Example
{% extends 'base.html.twig' %}
{% block body %}
{% trans_default_domain 'order_checkout' %}
{# Direct translation in template scope — inherits trans_default_domain #}
<h1>{{ 'title'|trans }}</h1>
{# 1 translation inside a component — inline domain #}
<twig:Alert>
<twig:block name="content">{{ 'warning'|trans(domain: 'order_checkout') }}</twig:block>
</twig:Alert>
{# 2+ translations inside a component — hoist trans_default_domain #}
<twig:Table>
{% trans_default_domain 'order_checkout' %}
<thead>
<tr>
<th>{{ 'col.name'|trans }}</th>
<th>{{ 'col.qty'|trans }}</th>
<th>{{ 'col.price'|trans }}</th>
</tr>
</thead>
</twig:Table>
{% endblock %}
Dates and Time Values in Templates
IMPORTANT: Never use Twig date functions directly in templates. Inject a ClockInterface (PSR-20) via a Twig component, a controller, or a Twig function/extension.
The Problem
{# WRONG: Global function in the template #}
<footer>Acme {{ "now"|date("Y") }}</footer>
Issues:
- Not testable (impossible to mock the date)
- Tight coupling with global functions
Solutions
Option 1: Via a Twig component
final class Layout
{
public function __construct(private readonly ClockInterface $clock) {}
public function getCurrentYear(): string
{
return $this->clock->now()->format('Y');
}
}
<footer>Acme {{ this.currentYear }}</footer>
Option 2: Via the controller
return $this->render('template.html.twig', [
'currentYear' => $this->clock->now()->format('Y'),
]);
<footer>Acme {{ currentYear }}</footer>
Option 3: Via a Twig function/extension
final class DateExtension extends AbstractExtension
{
public function __construct(private readonly ClockInterface $clock) {}
public function getFunctions(): array
{
return [
new TwigFunction('current_year', [$this, 'getCurrentYear']),
];
}
public function getCurrentYear(): string
{
return $this->clock->now()->format('Y');
}
}
<footer>Acme {{ current_year() }}</footer>
Benefits
- Testability: Mockable clock in tests
- PSR-20: Standard interface (
Psr\Clock\ClockInterface)
- Decoupling: No dependency on global functions
When to Set the Domain Explicitly
The parent's trans_default_domain does not propagate into:
<twig:*> component content
- Macros called from another file (imported macros)
- Any isolated rendering scope
Pick the form based on call count:
- 1 translation in the isolated scope → inline
(domain: '...')
- 2+ translations in the isolated scope → declare
{% trans_default_domain '...' %} once at the top of that scope
Quick Reference
| Context | Inherits parent trans_default_domain | 1 translation | 2+ translations |
|---|
| Direct in template | Yes | {{ 'key'|trans }} | {{ 'key'|trans }} |
Inside <twig:Component> | No | {{ 'key'|trans(domain: 'd') }} | {% trans_default_domain 'd' %} then {{ 'key'|trans }} |
| Inside macro (same file) | Yes | {{ 'key'|trans }} | {{ 'key'|trans }} |
| Inside imported macro | Depends | {{ 'key'|trans(domain: 'd') }} | {% trans_default_domain 'd' %} then {{ 'key'|trans }} |
Debugging Translation Issues
When a translation key appears as-is instead of translated:
- Check if you're inside a Twig component - Add explicit domain
- Verify the domain name - Check
translations/{domain}.{locale}.yaml exists
- Check key path - Nested keys use dot notation:
section.subsection.key
- Clear cache - Run
bin/console cache:clear