---
title: "Translations"
description: "Translations"
type: "docs"
category: "doc"
tags: []
authors: [Anonymous]
date: "2026-10-21"
last_update: "2026-10-21"
time_minutes: 8
draft: false
unlisted: false
url: "https://www.derafu.dev/docs/ui/twig/translations"
---

# Translations

`Derafu\Twig\Extension\TranslationExtension` brings message translation to
Twig templates, powered by [`derafu/translation`](https://www.derafu.dev/docs/core/translation).
It mirrors the structure of Symfony's own Twig translation integration (a
`trans` filter, a `t()` function, a {% verbatim %}`{% trans %}`{% endverbatim %} block tag, and a
{% verbatim %}`{% trans_default_domain %}`{% endverbatim %} tag), but without depending on
`symfony/twig-bridge` — that package's only hard requirements are
`symfony/translation-contracts` and `twig/twig`, but it still ships 55 files
(~500KB) of Csrf, Form, HttpKernel, Security, Serializer, and Workflow
extensions this library has no use for. `TranslationExtension` depends only
on `derafu/translation` instead.

Like the rest of this ecosystem, it does nothing unless you explicitly wire
a translator — without one, everything still renders, using ICU formatting
directly instead of a translation catalogue.

## Registering It

`TwigCreator` (and so `TwigService`) always registers a
`TranslationExtension`, so the templates can use `trans` without any setup.
That one has no translator: everything renders in English (see
[Fallback Without a Translator](#fallback-without-a-translator)). To translate,
give your own instance, with the translator, in the `extensions` option, the
same way you would add any other Twig extension. The one you give is the one
that is used, and the default one is not registered:

```php
use Derafu\Twig\Extension\TranslationExtension;
use Derafu\Twig\Service\TwigService;
use Derafu\Translation\TranslatorFactory;

$translator = TranslatorFactory::create(
    defaultLocale: 'es',
    fallbackLocales: ['es', 'en'],
);

$twigService = new TwigService([
    'extra' => false, // Keeps this example scoped to just this extension.
    'extensions' => [
        new TranslationExtension($translator, 'app+intl-icu'),
    ],
]);
```

The constructor takes three optional arguments:

```php
final class TranslationExtension extends AbstractExtension
{
    public function __construct(
        ?TranslatorInterface $translator = null,
        ?string $domain = null,
        ?string $locale = null,
    ) {
    }
}
```

`$domain`/`$locale` are fallbacks used only when a call site doesn't specify
its own — they are never assumed or required. Prefer
{% verbatim %}`{% trans_default_domain %}`{% endverbatim %} (below) to set a domain per template instead of
relying on this constructor argument, since a single `TranslationExtension`
instance is shared by every template rendered through that Twig
`Environment`.

## The `trans` Filter

{% verbatim %}
```twig
{{ 'Hello {name}!'|trans({'name': user.name}) }}
{{ 'Hello {name}!'|trans({'name': user.name}, 'app+intl-icu') }}
{{ 'Hello {name}!'|trans({'name': user.name}, 'app+intl-icu', 'fr') }}
```
{% endverbatim %}

Placeholders use ICU syntax (`{name}`), the same convention used everywhere
else in this ecosystem — never `%name%`. The domain should generally carry
the `+intl-icu` suffix (see
[the ICU MessageFormat guide](https://www.derafu.dev/docs/core/translation)); without it, the
underlying translator falls back to plain `strtr()`, which does not
understand `{name}`-style placeholders.

### A translatable value as the message

The filter also takes a value that is already translatable: a
`TranslatableMessage`, what `t()` makes, or a translatable exception. It brings
its own id, parameters and domain, so it is translated as it is:

{% verbatim %}
```twig
{{ message|trans }}                 {# A TranslatableMessage, for example a flash message. #}
{{ t('Close', {}, 'app+intl-icu')|trans }}
{{ error|trans }}                   {# A translatable exception. #}
{{ message|trans({}, null, 'fr') }} {# The locale can be given. #}
```
{% endverbatim %}

- Without a translator the value is its text. For an exception, it is its
  message, never the dump that PHP makes of it (class, file, line and trace).
- **Parameters can not be given** for a value that is already translatable: they
  would not be used, so the filter throws a `TwigException`, like Symfony's does.
  The domain is not checked, because the {% verbatim %}`{% trans_default_domain %}`{% endverbatim %} tag
  puts one in every call; the value keeps its own.
- A translatable value is also accepted as a **parameter** of a text, and it is
  translated too: {% verbatim %}`{{ 'Failed: {reason}'|trans({'reason': error}) }}`{% endverbatim %}.
- `null` and an empty text give an empty text. Any other `Stringable` is
  translated as a text.

## The `t()` Function

{% verbatim %}
```twig
{% set message = t('Hello {name}!', {'name': user.name}, 'app+intl-icu') %}
```
{% endverbatim %}

Builds a `Derafu\Translation\TranslatableMessage` without translating it —
useful to hand a translatable value to PHP code that will translate it
later, possibly with a different locale.

To translate it right there, apply the filter: {% verbatim %}`{{ t('Close')|trans }}`{% endverbatim %}.

**Printing it directly does not use the configured translator.** {% verbatim %}`{{ t(...) }}`{% endverbatim %}
casts the object to a string via `__toString()`, which always applies ICU
formatting only — the same fallback used when no translator is configured
at all. To translate a message immediately within a template, use the
`trans` filter or the {% verbatim %}`{% trans %}`{% endverbatim %} tag below, both of which call the
translator directly; reach for `t()` only when the translation needs to
happen somewhere else, later.

## The `trans` Block Tag

{% verbatim %}
```twig
{% trans %}Save{% endtrans %}
{% trans with {'name': user.name} %}Hello {name}!{% endtrans %}
{% trans with {'name': user.name} from 'app+intl-icu' %}Hello {name}!{% endtrans %}
{% trans with {'name': user.name} from 'app+intl-icu' into 'fr' %}Hello {name}!{% endtrans %}
```
{% endverbatim %}

Unlike Symfony's own `trans` tag, parameters are **never** auto-detected by
scanning the message text for `%name%` occurrences — that convention
predates ICU and is incompatible with it. Here, parameters always go
through `with`, explicitly, and placeholders in the text are ICU (`{name}`),
matching the `trans` filter.

The message must be plain text. Interpolating a Twig expression
({% verbatim %}`{% trans %}Hello {{ user.name }}!{% endtrans %}`{% endverbatim %}) raises a `SyntaxError`
at compile time: the translation id would depend on a runtime value and
would never match an entry in the translation catalogue.

{% verbatim %}
**Never use the bare `#` ICU plural shorthand — in any of `trans`, `t()`,
or `{% trans %}`.** A literal `{#` in raw template text is always lexed
by Twig itself as the start of a `{# comment #}`, before this extension's
code ever runs, breaking the whole template outright (or, worse, silently
consuming content up to the next stray `#}`). That alone rules out the
`{% trans %}` tag for any ICU plural/select message using `#`
(`{count, plural, one {# item} other {# items}}`), since its body is raw
template text.

A `{#` inside a Twig *string literal* — e.g. the message argument to the
`trans` filter — is lexed literally and is safe **when only Twig's own
extensions are registered**. That safety is not guaranteed once
`symfony/ux-twig-component` is also registered (the runtime behind
`<twig:...>` component tags): a real case surfaced where the exact same
`{#`, inside a `trans` filter's string argument, in a template that both
used a `<twig:...>` component and `{% extends %}`, broke the template
with an unrelated-looking error
(`A template that extends another one cannot include content outside
Twig blocks`) — nothing in the message pointed at `{#` as the cause. The
exact mechanism inside `ux-twig-component` was not identified (it is a
third-party dependency), but bisection on the real template confirmed
the `{#` substring as the trigger.

**The rule that holds regardless of which extensions are registered:**
never write the bare `#` shorthand in an ICU plural/select message. This
applies to `trans`, `t()`, and `{% trans %}` alike.

The fix is **not** to reference the same argument again as `{count}`:
real ICU (PHP's `MessageFormatter`, used both by the no-translator
fallback and, through Symfony, by a real translator) throws
`U_ARGUMENT_TYPE_MISMATCH` when the same argument name is used both as
the plural selector and as a literal reference inside a branch — `#`
exists specifically to avoid that conflict, it is not interchangeable
with a named reference to the same argument. The safe fix is to pass the
same value under **two different argument names**: one used only for
plural selection, one only for display:

```twig
{{ '{count, plural, one {{n} item} other {{n} items}}'|trans({'count': items|length, 'n': items|length}, 'app+intl-icu') }}
```
{% endverbatim %}

## The `trans_default_domain` Tag

Sets a default domain for every `trans` filter call and {% verbatim %}`{% trans %}`{% endverbatim %} block
that follows it in the same template or block, and doesn't have its own
domain:

{% verbatim %}
```twig
{% trans_default_domain 'app+intl-icu' %}

{{ 'Save'|trans }}
{% trans %}Cancel{% endtrans %}
{{ 'Delete'|trans({}, 'admin+intl-icu') }} {# explicit domain still wins #}
```
{% endverbatim %}

The domain doesn't need to be a literal string — a dynamic expression works
too, evaluated once regardless of how many `trans` calls use it:

{% verbatim %}
```twig
{% trans_default_domain app.locale == 'es' ? 'es+intl-icu' : 'en+intl-icu' %}
```
{% endverbatim %}

The scope is the enclosing template or {% verbatim %}`{% block %}`{% endverbatim %} — a
`trans_default_domain` set inside a block does not leak to the rest of the
template, and the template's own domain (if any) is restored once the block
ends.

## Fallback Without a Translator

`trans`, `t()`, and {% verbatim %}`{% trans %}`{% endverbatim %} all work with no `Translator` configured
at all — every one of them falls back to
`TranslatableMessage::__toString()`, which still applies ICU formatting
(interpolating `{name}`-style placeholders correctly) even though nothing
gets translated:

{% verbatim %}
```twig
{# No translator registered: renders "Hello Juan!" literally. #}
{{ 'Hello {name}!'|trans({'name': user.name}) }}
```
{% endverbatim %}

This is unlike `symfony/twig-bridge`, whose own no-translator fallback uses
a plain `strtr()`-based identity translator that does not understand ICU
placeholders at all.



---
Last updated on 21/10/2026

