Skip to Content
FrontendTranslationsLang Translations API & Usage

Lang Translations API & Usage

How multi-language content works in the ERP CRUD: the lang_translations API, the TranslatableInterface that entities implement, and the read/write flow the frontend uses.


Two kinds of translation

SystemWhat it translatesWhere it lives
UI translations (react-intl)Static interface strings (labels, buttons, sections)messages JSON files in each app (en.json / fr.json) — see App Setup
Lang Translations (this page)Entity data (a title, a description) per localeBackend API, stored as lang_translations rows

This page is about the second one. The translatable entity data.

Which entities are translatable

An entity becomes translatable on the backend by implementing PHPReaction\Entity\LangBundle\TranslatableInterface. You’ll see it listed in the entity’s implementedInterfaces metadata, next to the other behaviours it supports like Prioritizable, Sluggable, or Disableable. The CRUD generator picks that up and turns it into a simple translatable flag:

const implementedInterfaces = { translatable: true, // ... };

Once that flag is on, the CRUD adds a language switcher to the create and edit forms, the Translation Modal for editing every locale at once, translation-aware search filters, and translatable content in the quick show panel.

For the details on either side of this, see the frontend interface map, the backend interface behaviour, and the backend implementation (listeners, doctrine filters, and the query params).

Reading translations

A translatable entity resolves its translation for whatever locale the request is in (set with the _locale query param) and returns the result inline. So a plain GET on an invoice_type already gives you the translated title for the current locale, along with a few helper fields:

{ "title": "Title en", "resolvedLocale": "en_CA", "resolvedLocaleMessage": "Translation for en_CA found.", "noTranslationForTitle": false, "noTranslationForDescription": true, "missingCurrentLocaleTranslation": false, "translationsListing": "/open-api/v3/invoice_typetranslations?translatable=333" }

You mostly just read title and description. The resolvedLocaleMessage, noTranslationFor*, and missingCurrentLocaleTranslation fields are there so the UI can warn the user when a locale hasn’t been filled in yet.

If you need the raw translation rows rather than the resolved value, every translatable entity exposes a translationsListing link to its own rows, and you can query the shared table directly:

GET /open-api/v3/invoice_typetranslations?translatable=333 GET /open-api/v3/lang_translations?title=QST

The lang bundle also exposes langs (the languages themselves), lang_language, and lang_alerts, and each translatable entity has its own translations resource such as invoice_typetranslations or bill_linetypetranslations.

description is stored in lang_translations exactly like title, but it isn’t exposed as a virtual property in the entity metadata yet, so virtual_properties only lists title. See phpreaction-frontend-crud-react-v2#1159 .

Writing translations

When you create or edit an entity, send every locale at once in an apiTranslations object, keyed by locale:

{ "priority": 1, "apiTranslations": { "en_CA": { "title": "Title en", "description": "" }, "fr_CA": { "title": "Titre fr", "description": "" }, "es": { "title": "", "description": "" } } }

In a form this is just the shape React Hook Form holds for you (see Form Components):

const form = useForm({ defaultValues: { priority: 1, apiTranslations: { en_CA: { title: "", description: "" }, fr_CA: { title: "", description: "" }, es: { title: "", description: "" }, }, }, });

Don’t send a bare translations object like { "translations": { "title": "…" } }. The API can’t denormalize that and fails with Could not denormalize object of type "…TranslationInterface[]", no supporting normalizer found. Always use the locale-keyed apiTranslations shape. See phpreaction-frontend-crud-react-v2#248 .

The translation modal

You never build the apiTranslations object by hand in a form. When an entity is translatable, the quick-add and quick-edit modals drop in the TranslatableFields panel, and that panel is what fills apiTranslations for you.

It’s a set of tabs, one per language (French, English, Spanish by default), and each tab holds that locale’s title and description. French comes first and its title is marked required with a *; the others are optional. As you type, the panel writes straight into apiTranslations.fr_CA.title, apiTranslations.en_CA.title, and so on, so the value that leaves the modal is already in the shape the API wants. Validation errors from every locale are collected under the tabs and shown together, each prefixed with the language it came from, e.g. [EN] EN title is required, so a user can’t miss a required field just because it lives on a tab they never opened.

Translatable entity modal showing FR / EN / ES tabs with the French title required

Which locales are required

Only the French title is required by default. A few entities need more than that. Products, for example, expect a title in French, English, and Spanish, and posting one with the others blank comes back as a validation error:

translations[en_CA].title: This value should not be blank. translations[es].title: This value should not be blank.

Right now the frontend only finds this out after the request fails, because the required locales aren’t in the metadata yet. Exposing required locales in metadata (so forms can mark them up front) is planned: generator#273  and generator#274 .

Languages and fallback

Languages are referenced by a slug, for example fr_CA for French or en_CA for English, and that same list feeds the language menu in the footer. When a translation is missing for the current locale the entity doesn’t just blank the field: it tells you through resolvedLocaleMessage ("No translations were found for this resource") and the noTranslationFor* flags, so the UI can react.

The translations sidebox

Translations also show up as a sidebox on a record’s detail/show page: the translatable rows for the current record, embedded under it, with a quick-add modal that opens the same TranslatableFields panel. It adds no new translation behaviour of its own, it’s the same read/write flow described above, just embedded in a parent’s detail view. The sidebox mechanics (lazy loading, header actions, deep-linking, print behaviour) and how it fits the broader record-binding relations are documented in Sideboxes & Relations.

Expanded translations sidebox on a detail page with the add, advance-options, and listing actions

How the apps use it

The POS app pulls translations from the API (/open-api/v3/lang_translations?title=…) and lets them override its static messages files, with caching to keep the calls cheap (pos#326 ). Inventory adds its own custom translations for entity labels (inventory#249 ), and Login is getting full Spanish support (login#498 ).

Components:

Tracking issues:

Last updated on