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 stringsInputActionResolvercan produce (validation error messages come fromderafu/data-processor‘s own translations instead —derafu/formdoesn’t duplicate them).Derafu\Form\Translation\FormTranslationResourceProvider— aTranslationResourceProviderInterfaceimplementation pointing at that directory.Derafu\Form\Factory\TranslatingFormFactoryandDerafu\Form\Translation\FormTexts(the texts of a form, section 3), andDerafu\Form\Lint\FormTranslationAuditto 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.