Translations

derafu/form has three independent, optional translation surfaces. All of them are powered by derafu/translation, and all of them do nothing unless you explicitly wire a translator — without one, everything renders in English exactly as before.

1. Validation Error Messages

FormDataProcessor runs field values through derafu/data-processor rules. When a rule fails, it throws a Derafu\DataProcessor\Exception\ValidationException (or one of the other translatable exceptions from that package — see its Translations guide). FormDataProcessor accepts an optional translator and locale; when given, it translates each error before adding it to the result:

final class FormDataProcessor implements FormDataProcessorInterface
{
    public function __construct(
        FormRulesResolverInterface $resolver,
        ProcessorInterface $processor,
        UiSchemaRuleEvaluatorInterface $evaluator = new UiSchemaRuleEvaluator(),
        ?TranslatorInterface $translator = null,
        ?string $locale = null,
    ) {
    }
}

Without a translator, getErrors() returns the untranslated (English) messages — the exact same behavior as before this parameter existed.

2. Input Action Labels

Controls rendered with the Control UI schema type can declare options.actions — input-group buttons like “show/hide password”, “generate a random password”, or “copy the field’s value”. Their labels (and the default copy confirmation message) are resolved by InputActionResolver, which also accepts an optional translator:

final class InputActionResolver
{
    public function __construct(
        ?TranslatorInterface $translator = null,
        ?string $locale = null,
    ) {
    }
}

An explicit label or message given directly in options.actions is always used as-is and never translated — only the three built-in defaults are:

[
    'type' => 'Control',
    'scope' => '#/properties/password',
    'options' => [
        'actions' => ['toggle-password'],
    ],
]

InputActionResolver isn’t wired into the rendering pipeline directly — ElementRendererProvider (used internally by FormRendererFactory) forwards it to ControlRenderer. To use a translator-aware resolver, pass your own ElementRendererProvider via the element_renderers factory option (see Activating It below).

3. The Texts of a Form

The titles, descriptions, labels, help texts, placeholders and options that the author of a form writes are translated when the form is created. The author writes them in English, as normal text, and each form says in which translation domain they are, with the option translation_domain (in the options of the definition, where the other options of the form are):

# resources/forms/contact-form.yaml
options:
    translation_domain: contact-form

schema:
    type: object
    properties:
        name:
            type: string
            title: Your Name
uischema:
    type: VerticalLayout
    elements:
        - type: Control
          scope: '#/properties/name'
          options:
              placeholder: Type your name

Derafu\Form\Factory\TranslatingFormFactory decorates the form factory, which is where every form ends: the loaders (PHP, YAML and JSON) and whoever builds a definition by hand call create(). The text in English is the id of the translation, the domain is the options.translation_domain of the definition, and the language is the one of the translator when the form is created. A text without an entry in the catalogue stays as it was written, and a definition without options.translation_domain is not touched.

The catalogue is resources/translations/<domain>+intl-icu.<locale>.php in the directory of the resource provider of the package or the site. A site can have as many domains as it wants: every file of the directory is loaded.

Which texts

Only these keys are texts. Everything else is data or configuration and is never translated.

Where Keys
The schema: the form and each property (also inside items, $defs and definitions) title, description
The options of a property: each element of oneOf, anyOf and allOf title
Each element of the UI schema (also inside elements, and inside options.detail) label, and text for a Label
The options of an element placeholder, help, footer, error_message, label, unit, input_group_prepend_text, input_group_append_text
The choices of an element (and cascade.choices) The title of each choice: the values of the dict, never its keys (they are the values of the field)

The list lives in Derafu\Form\Translation\FormTexts, and the factory and the audit use the same one, so what is translated and what is audited are always the same.

What is not in the list: PHP

A form that needs to translate something that is not in the list (a text in options.attr, for example), or a text with parameters, is written in PHP. PhpFormLoader receives an optional translator, and when it has one the closure of the file gets in its context translator and _t, a function (string $id, array $parameters = [], string $domain = 'messages'). What the caller already put in the context under those names is not replaced:

// resources/forms/profile.form.php
return function (array $context = []): array {
    return [
        'options' => ['translation_domain' => 'profile'],
        'schema' => [
            'type' => 'object',
            'properties' => [
                'name' => [
                    'type' => 'string',
                    'title' => $context['_t']('Hello {name}', ['name' => $context['user']], 'profile'),
                ],
            ],
        ],
    ];
};

The result of the closure also goes through the factory, so the texts of the list are translated too if the definition has options.translation_domain. A text that was already translated has no entry in the catalogue and stays as it is.

Auditing the texts

Derafu\Form\Lint\FormTranslationAudit reads the definitions of a directory (YAML, JSON and PHP) without running them and checks their texts against the catalogues of the package. It needs symfony/yaml and nikic/php-parser, as development dependencies. Like the audit of derafu/translation, it only gives facts, and the test says which ones are a problem:

$report = (new FormTranslationAudit())->audit(
    dirname(__DIR__, 2) . '/resources/forms',
    new MyPackageTranslationResourceProvider(),
);

$this->assertFalse($report->nothingFound);
$this->assertSame([], $report->describe($report->dynamicTexts));
$this->assertSame([], $report->describe($report->withoutDomain));
$this->assertSame([], $report->describe($report->missingTranslations));
$this->assertSame([], $report->describe($report->notUsedBySources));
Property The fact
texts The texts that can be read and have a domain.
dynamicTexts Texts that are not a literal, so they can not be checked: a call of a local function ($t('Name')), a concatenation, a call of _t with a variable.
withoutDomain Texts of definitions without options.translation_domain: they are not translated.
missingTranslations Texts without an entry in the catalogue of their domain.
notUsedBySources Entries of the catalogues, in the domains that the definitions use, that no text uses. If the domain is shared with templates or code, use it as a list to subtract from (the other audits flag these entries as unused).

The calls $context['_t']('Text', [], 'domain') are found wherever they are, because they are written for what is not in the list. To have a PHP form audited, write them with that form; a local function ($t = fn ...) works, but its texts are reported as dynamic and are not audited. A PHP definition is read when it is an array written in the file, and a part built in a variable is not read.

The loaders in the container

form-services.yaml registers PhpFormLoader and YamlFormLoader for the forms of the application, with the directory resources/forms of the project (<name>.form.php and <name>.form.yaml). They are private services: an application that does not use them does not have them in its container. The PhpFormLoader gets the translator of the container, so its closures receive _t, and both create the form with the factory that translates. FormLoaderInterface is not set: which loader it is, or a chain of them (ChainFormLoader), is a decision of each application:

services:
    Derafu\Form\Contract\Loader\FormLoaderInterface:
        alias: Derafu\Form\Loader\PhpFormLoader

What Ships

derafu/form includes:

  • resources/translations/form+intl-icu.es.php — a Spanish translation for the four strings InputActionResolver can produce (validation error messages come from derafu/data-processor‘s own translations instead — derafu/form doesn’t duplicate them).
  • Derafu\Form\Translation\FormTranslationResourceProvider — a TranslationResourceProviderInterface implementation pointing at that directory.
  • Derafu\Form\Factory\TranslatingFormFactory and Derafu\Form\Translation\FormTexts (the texts of a form, section 3), and Derafu\Form\Lint\FormTranslationAudit to audit them.

Neither does anything on its own — derafu/form is a library, it doesn’t build or own a Translator.

Activating It (Plain PHP)

use Derafu\DataProcessor\ProcessorFactory;
use Derafu\DataProcessor\Translation\DataProcessorTranslationResourceProvider;
use Derafu\Form\Processor\FormDataProcessor;
use Derafu\Form\Processor\FormRulesResolver;
use Derafu\Form\Translation\FormTranslationResourceProvider;
use Derafu\Translation\TranslatorFactory;

$translator = TranslatorFactory::create(
    defaultLocale: 'es',
    fallbackLocales: ['es', 'en'],
    resourceProviders: [
        new DataProcessorTranslationResourceProvider(), // Validation error messages.
        new FormTranslationResourceProvider(),           // Input action labels.
    ],
);

$processor = new FormDataProcessor(
    new FormRulesResolver(),
    ProcessorFactory::create(),
    translator: $translator,
    locale: 'es',
);

For the action labels, build the renderer with a translator-aware ElementRendererProvider:

use Derafu\Form\Factory\FormRendererFactory;
use Derafu\Form\Renderer\ElementRendererProvider;
use Derafu\Form\Renderer\Support\InputActionResolver;

$formRenderer = FormRendererFactory::create([
    'element_renderers' => new ElementRendererProvider(
        actionResolver: new InputActionResolver($translator, 'es'),
    ),
]);

Activating It (Dependency Injection)

If your application uses symfony/dependency-injection, import all three recipe files. form-services.yaml already registers InputActionResolver and ElementRendererProvider as autowired services, and wires FormRendererInterface’s element_renderers option to that ElementRendererProvider — nothing extra to configure beyond registering your own Translator. It also decorates the form factory with TranslatingFormFactory, which needs the TranslatorInterface that translation-services.yaml provides, so import that file as well:

imports:
    - { resource: '../vendor/derafu/translation/resources/config/translation-services.yaml' }
    - { resource: '../vendor/derafu/data-processor/resources/config/data-processor-services.yaml' }
    - { resource: '../vendor/derafu/form/resources/config/form-services.yaml' }

services:
    Symfony\Component\Translation\Translator:
        factory: ['Derafu\Translation\TranslatorFactory', 'create']
        arguments:
            $defaultLocale: 'es'
            $fallbackLocales: ['es', 'en']
            $resourceProviders: !tagged_iterator derafu_translation.resource_provider

Both DataProcessorTranslationResourceProvider and FormTranslationResourceProvider are already tagged derafu_translation.resource_provider in their respective recipe files, so they’re picked up automatically. FormDataProcessor and InputActionResolver both autowire ?TranslatorInterface, so they resolve to the Translator above via the alias translation-services.yaml provides — no explicit argument wiring needed for either of them.

On this page

Last updated on 21/10/2026 by Anonymous