Translation entity - localized field values¶
Overview¶
The Translation entity (table civicrm_translation) stores alternate-language versions of individual
field values on other entities - for example the title or description of a civicrm_event, or the
msg_subject / msg_html / msg_text of a civicrm_msg_template. Each row is one field, on one record,
in one language.
CiviCRM actually has four distinct, overlapping-sounding translation mechanisms. This page covers the first row below; the others are mentioned only for contrast, with links to where they're properly documented:
| Mechanism | What it translates | Where it lives | How it's selected |
|---|---|---|---|
Translation (this page) |
Values stored in specific entity fields, e.g. civicrm_event.title |
civicrm_translation, keyed by entity_table / entity_id / entity_field |
APIv4 setLanguage() + setTranslationMode('fuzzy') |
TranslationSource |
Free-form/admin-managed strings not tied to one field, e.g. Afform layout text | civicrm_translation + civicrm_translation_source, keyed by a hash of the source text |
Consulted automatically inside ts() / _ts() - see TranslationSource |
Gettext (ts()) |
CiviCRM's own hard-coded UI strings | .mo language pack files |
ts() / E::ts() - see Translation for Developers |
| Legacy multi-lingual schema | Localized entity columns | Extra columns on the entity's own table (title_en_US, title_fr_FR, ...) |
Multilingual schema/view logic - see Database localized fields and upgrades |
(The one bridge between rows 1/2 and row 3: TranslationSource-linked translations are substituted
inside ts() itself, before gettext runs - see How it's consulted
below.)
The Translation entity stores translations in the civicrm_translation table, referencing the original
record via entity_table / entity_id / entity_field rather than adding columns to the entity's own
table, so any number of languages can be added for a record without altering its schema, and a translation
can be saved as a draft before it goes live.
Which fields can be translated?¶
Not every field on every entity is eligible. An extension or core component must opt a field in via
hook_civicrm_translateFields():
/**
* @see CRM_Utils_Hook::translateFields()
*/
function myextension_civicrm_translateFields(&$fields) {
$fields['civicrm_event']['title'] = TRUE;
$fields['civicrm_event']['description'] = TRUE;
}
CRM_Core_BAO_Translation::getTranslatedFields() collects and caches these declarations. They only drive
the entity_table / entity_field pick-lists (getEntityTables() / getEntityFields()) shown in the
admin UI - registering a field via the hook doesn't restrict what the API itself will accept.
CRM_Core_BAO_Translation::self_civi_api4_validate() never checks hook registration; it only checks that
the target field is a plain string/text field (html.type of Text, TextArea, RichTextEditor, or
none). So calling the API directly can attach a Translation to any field of a suitable type, whether or
not it was registered via the hook.
That field-type check itself only applies to entity/field-based translations - it's skipped entirely for
source_key-based translations (see TranslationSource), since those don't reference
an entity_table / entity_id / entity_field at all.
Fields on the Translation entity¶
| Field | Description |
|---|---|
entity_table |
The table of the record being translated, e.g. civicrm_event. |
entity_field |
The column being translated, e.g. title. |
entity_id |
The ID of the specific record being translated. |
language |
The language of this translation, e.g. fr_CA. |
status_id |
active or draft - see below. |
string |
The translated text. |
source_key |
An alternate FK, linking to TranslationSource instead of entity_table/entity_id - see below. |
entity_table, entity_field, and entity_id must either all be supplied together, or all omitted (the
latter is used with source_key instead - see TranslationSource).
No database-level uniqueness constraint
Nothing in the schema enforces "one active translation per field/language" (or per source_key /
language / status_id) - getIndices() on both Translation and TranslationSource defines only
non-unique lookup indexes (TranslationSource.source_key is the one exception). Writing directly can
leave duplicate or conflicting rows. The core admin UI - and the examples on this page - avoid that by
using setMatch() on Translation::save() / replace() to find-and-update an existing row rather than
blindly inserting; do the same in your own code.
Retrieving translated values¶
Generic APIv4 get¶
Any DAOGetAction (i.e. a standard APIv4 get on an entity) can request translated output by setting a
language and switching on translation mode:
$events = Event::get()
->setLanguage('es_MX')
->setTranslationMode('fuzzy')
->addSelect('id', 'title', 'description', 'summary')
->execute();
setLanguage() (inherited from AbstractAction) causes Civi\Core\Locale::negotiate() to be applied for
the duration of the call (see Civi\API\Subscriber\I18nSubscriber); setTranslationMode('fuzzy') tells
CRM_Core_BAO_Translation::hook_civicrm_apiWrappers() to register a wrapper
(CRM_Core_BAO_TranslateGetWrapper) that overlays each returned record with any applicable translated
field values, following the fallback rules below. Each returned record also gets an
extra actual_language key, indicating which language was actually used to satisfy the translation (which
may differ from the negotiated language you asked for - see terminology below).
fuzzy is currently the only implemented translation mode (a strict mode is reserved in the option list
but not yet implemented).
WorkflowMessage::render()¶
Rendering a workflow message (e.g. an event or contribution receipt) supports the same setLanguage()
option, which is threaded through to the underlying MessageTemplate::get() lookup:
$rendered = WorkflowMessage::render()
->setWorkflow('contribution_online_receipt')
->setLanguage('fr_CA')
->setValues(['contributionID' => $contributionId])
->execute()
->first();
Internally, Civi\WorkflowMessage\Traits\TemplateTrait::loadTemplate() calls:
MessageTemplate::get(FALSE)
->setLanguage($language)
->setTranslationMode('fuzzy')
->addWhere('workflow_name', '=', $workflowName)
// ...
If no explicit setLanguage() is given, the model falls back to the target contact's preferred_language.
After rendering, the model's locale is updated to whichever language was actually used
(actual_language), while requestedLocale records the preferred language - what was originally asked
for, before negotiation - useful in custom workflow messages that want to branch on the preferred language
even when the rendered template came from a fallback language.
Managing translations directly¶
Translations are ordinary records, manageable via the Translation API entity. For example, to add a set
of French Canadian translations for a message template's subject/html/text in one call:
Translation::save()
->setDefaults([
'entity_table' => 'civicrm_msg_template',
'entity_id' => $messageTemplateId,
'language' => 'fr_CA',
'status_id:name' => 'active',
])
->setRecords([
['entity_field' => 'msg_subject', 'string' => 'Bonjour'],
['entity_field' => 'msg_html', 'string' => '<p>Voila!</p>'],
['entity_field' => 'msg_text', 'string' => 'Voila!'],
])
->execute();
Or to save a single draft translation pending review:
Translation::create()
->setValues([
'entity_table' => 'civicrm_event',
'entity_field' => 'description',
'entity_id' => $eventId,
'language' => 'es_MX',
'status_id:name' => 'draft',
'string' => 'Descripción en borrador',
])
->execute();
Fallback resolution¶
A translation does not need to exist for every field of an entity, and for a given field, a translation
does not need to exist in every language. When resolving a value, CiviCRM works through several possible
sources, from most to least specific, and this choice is made independently for each field - having an
es_MX translation for title does not stop description from falling back to a different language (or
to the raw value).
Terminology¶
- Preferred language
- The language you ask for - the value passed to
setLanguage(), or (for a message with no explicit language) the target contact'spreferred_language. This is a preference, not a guarantee - it still needs to be negotiated. - Negotiated language
- The locale
Civi\Core\Locale::negotiate()actually selects for the request, based on the preferred language and which locales the site allows (see Partial Locales below). This is held inCivi\Core\Locale::detect()->nominalfor the duration of the call. It may differ from the preferred language. ForTranslationlookups specifically, this value is only the *starting point* for a second, entity-scoped negotiation - see [Resolution order](#resolution-order) below for what actually decides step 1. - Language family
- The set of registered languages sharing the same base language code (the part before the underscore) - e.g.
es_MX,es_ESandes_419all belong to theesfamily. Fallback within a family is not simply "drop the regional suffix and try the bare language code" (there is no genericeslocale to fall back to) - CiviCRM instead picks among whichever variants are actually registered, using a fixed precedence order defined in code. See the note below. - Default language
- The language configured as the site's CiviCRM default language (the
lcMessagessetting). - Translation in the default language
- A stored, active
Translationrecord for that field in the site's default language - not the same thing as the original/raw value below, even though the two are often the same text. It is deliberately preferred over the raw value: once a site is managing a field's content as aTranslationrecord (e.g. via a review/draft workflow), that default-language record becomes the authoritative content for the field, so admins can edit it through the same interface used for every other language rather than maintaining the text in two places. - Original / raw value
- The value stored directly on the entity's own column - used only when no applicable translation exists in any language, including the default language.
Family precedence order is an implementation detail
The precedence order within a family (e.g. for es: es_419, es_MX, es_ES) comes from
Civi\Core\Locale::getLocalePrecedence() - it's not a general algorithm, and shouldn't be treated as a
stable guarantee; check that method directly for the current list. Which languages count as the same
family in the first place is a separate, private concern of CRM_Core_BAO_Translation itself
(isSameFamily() / isNorwegianLocale()): normally grouped by base language code, with one exception -
Norwegian's regional variants are grouped by their shared country suffix (_NO) instead, since they
don't share a language-code prefix.
Why a precedence order exists at all: the general assumption is that a site will have translations in
whichever specific languages matter to its own audience, not a whole option family - so ties within a
family are expected to be rare. The order mainly matters for a contact whose preferred language the site
has no translation for at all, where CiviCRM has to pick the closest available substitute. For example, if
a site has translations in both en_US and en_NZ, but needs to render for a contact whose preferred
language is en_SG (Singaporean English - not a language the site has any translation for), most people
would expect en_US to be chosen over en_NZ, since US English is the more broadly-recognized default.
That's exactly what the precedence order (en_US, en_GB, en_AU, en_NZ) produces.
There's a third, unrelated concept worth keeping distinct from the two above: the language column on an
individual Translation row (the Fields table above) - the language a
specific stored translation is written in. Several rows, in different languages, may all be candidates
for the same field; which one is actually used is exactly what the resolution order below determines.
Resolution order¶
For each translatable field, CiviCRM works through these options in order and uses the first one available.
Step 1's "exact match" isn't tested directly against the negotiated language from the terminology above -
CRM_Core_BAO_Translation::getTranslatedFieldsForRequest() re-negotiates a second time
(Civi\Core\Locale::renegotiate()), restricted to whichever languages actually have Translation rows for
this specific entity/table. In the common case that lands on the same language; it can differ if the entity
has no translation in the negotiated language but does have one in a same-family sibling that outranks it
in the family precedence order.
- An active translation in the exact (entity-scoped-re-negotiated) language.
- An active translation in another language from the same language family as the negotiated language (per the family's precedence order above).
- An active translation stored in the site's default language (not the raw value - see terminology above).
- The original/raw field value.
No tie-break among multiple non-preferred sibling languages
The family precedence order only decides which single language gets negotiated in the first place
(step 1, via Civi\Core\Locale::negotiate() / renegotiate()). If, after that, more than one other
sibling language - neither the negotiated language nor the default - happens to have a translation for
the same still-missing field, there is no further preference applied between them: e.g. if neither the
negotiated es_MX nor the default language has a value for description, but both es_ES and es_CO
do, which one wins is whatever order the underlying SQL query happens to return rows in - unspecified,
and not guaranteed stable across requests. There's no "es_ES beats es_CO" rule, even though that
might be the intuitive human expectation for Spanish specifically. This is treated as a gap rather than
an intentional design choice - a follow-up is expected to make it deterministic by reusing the same
precedence order used for language negotiation - but until then, don't rely on a particular sibling
winning in this situation.
draft translations are never used to satisfy a request automatically - only active translations
participate in this fallback chain, regardless of language.
Worked example¶
Site default language is en_US; the negotiated language is es_MX. es_ES translations also happen to
exist for this record, but es_MX does not exist for every field. The en_US column below is a stored
translation record in the default language (not the raw column value - see the note row, which has no
en_US translation record at all and so falls all the way through to the raw value):
| Field | es_MX translation |
es_ES translation |
en_US translation (default language) |
Raw column value | Result | Why |
|---|---|---|---|---|---|---|
title |
Hola | - | Hello | Hello | Hola | Exact negotiated-language match |
description |
- | Descripción | Description | Description | Descripción | No es_MX, falls back within the es family to es_ES |
summary |
- | - | Summary | Summary | Summary | No es translation at all, falls back to the translation in the default language |
note |
- | - | - | Original note text | Original note text | No translation in any language, including the default one, so the raw value is used |
The APIv4 actual_language marker is a record-level value, but it's derived deliberately rather than
via any "majority" computation. CRM_Core_BAO_TranslateGetWrapper::pickLanguage() looks at the languages
that supplied the fields actually selected for that record, then walks the same priority order used for
field resolution above, returning the first language that's represented among those fields. In this
example that's es_MX: title came from es_MX - the negotiated language, which outranks the
es_ES/default-language sources used for description/summary - so actual_language is es_MX, even
though it isn't the language every field actually came from. It always reflects a real source among the
fields returned, never an assumption about most/all of them: if none of a record's returned fields happened
to come from es_MX, actual_language would report whichever lower-priority language did contribute.
(It's subject to the same sibling-language tie-break gap as the field values
themselves.)
Edge cases: empty vs. missing¶
- An explicitly stored empty string (
string => '') counts as a translation, not as "no translation."CRM_Core_BAO_TranslateGetWrapper::toApiOutput()merges in whatever value is stored for a field key that exists in the resolved translation set, regardless of whether that value is an empty string, so it overwrites the original/raw value and does not trigger further fallback down the resolution order. Saving''for a field in a given language means "in this language, this field is intentionally blank," not "skip this field and fall back." - A field with no matching entry in the resolved translation set is left completely untouched,
including when the underlying raw value is
NULL- the wrapper only merges keys that exist in its translated-fields map, so an absent key means the original column value (whatever it is, includingNULL) passes through unchanged. - Individual consumers of translated data can layer their own handling on top of this. For example,
WorkflowMessage::render()drops a message part entirely (e.g. omitstextfrom its result) when the resolvedmsg_textis empty, rather than rendering an empty string - that's a decision made by the message-rendering code, not a general rule of theTranslationentity itself.
Active vs draft¶
Every Translation record has a status_id of either active or draft. Only active translations are
served by the fallback resolution above. draft lets an admin/translator prepare
or revise a translation without it going live - for example when an approval workflow is layered on top -
and it will never silently take priority over an existing active translation, nor be used as a substitute
when no active translation exists.
Example: draft-then-activate workflow¶
This is the pattern the core Message Template admin UI (ext/message_admin, crmMsgadm/Edit.js) uses to
let someone prepare a translation and review it (via the same preview dialog used for the live version)
before it goes live, without ever exposing half-finished text to end users.
-
Save a draft. A translator edits the subject/html/text for a language; the UI writes it as
status_id:name => 'draft', leaving any existingactiverecords for that language untouched:Translation::save() ->setDefaults([ 'entity_table' => 'civicrm_msg_template', 'entity_id' => $messageTemplateId, 'language' => 'fr_CA', 'status_id:name' => 'draft', ]) ->setRecords([ ['entity_field' => 'msg_subject', 'string' => 'Bonjour (brouillon)'], ['entity_field' => 'msg_html', 'string' => '<p>Voila! (brouillon)</p>'], ]) ->execute();
While only a draft exists, get/render calls for fr_CA are unaffected - they keep using whatever
active translation (or fallback) already applies.
-
Preview it. The admin UI's preview dialog can render the
draftrevision directly (it just queriesTranslation::get()filtered tostatus_id:name = 'draft'), so reviewers see exactly what would go live, side-by-side with the currentactivetranslation, before committing to anything. -
Activate the draft. Once approved, the draft's field values are copied onto the
activerecords (overwriting them) and the draft is removed. APIv4'sreplaceaction does the "make these records exactly match this set" part in one call:// Copy the reviewed draft strings onto the active set... $draftFields = Translation::get(FALSE) ->addWhere('entity_table', '=', 'civicrm_msg_template') ->addWhere('entity_id', '=', $messageTemplateId) ->addWhere('language', '=', 'fr_CA') ->addWhere('status_id:name', '=', 'draft') ->addSelect('entity_field', 'string') ->execute(); $records = []; foreach ($draftFields as $field) { $records[] = ['entity_field' => $field['entity_field'], 'string' => $field['string']]; } Translation::replace() ->setRecords($records) ->addWhere('entity_table', '=', 'civicrm_msg_template') ->addWhere('entity_id', '=', $messageTemplateId) ->addWhere('language', '=', 'fr_CA') ->addWhere('status_id:name', '=', 'active') ->execute(); // ...then discard the draft. Translation::delete() ->addWhere('entity_table', '=', 'civicrm_msg_template') ->addWhere('entity_id', '=', $messageTemplateId) ->addWhere('language', '=', 'fr_CA') ->addWhere('status_id:name', '=', 'draft') ->execute();
From this point on, fr_CA requests resolve to the newly-activated text. A reviewer can equally
"abandon" a draft by just deleting it (step 3's delete alone, skipping the replace) without ever
affecting the live translation.
TranslationSource¶
civicrm_translation_source (added 6.7) is a related mechanism for translating free-form text that isn't
tied to one specific field on one specific record - most notably, text embedded directly in an Afform
layout (which isn't a database column at all, and can't be registered via hook_civicrm_translateFields()).
Schema¶
| Field | Description |
|---|---|
source |
The literal source text to be translated, e.g. Thank you for registering!. |
source_key |
hash(source) - a short hash of the source text, generated by CRM_Core_BAO_TranslationSource::createGuid(). Used as the natural key, so re-scanning the same text twice doesn't duplicate the row. |
context_key |
hash(entity_name, entity_id, entity_field, entity) - identifies the context in which the source string was discovered (e.g. which Afform). It does not participate in translation lookup at all (only source_key does) - it exists purely for disambiguation/traceability. |
entity |
A free-text label for the kind of thing this string came from, e.g. afform. |
A Translation record then attaches to a source string by setting source_key to match, instead of
populating entity_table / entity_id / entity_field (see the note on those fields being mutually
exclusive with source_key, above).
Because source_key is a hash of the literal source text, identity is tied exactly to that text: editing
the source string - even a trivial change like adding a full stop - produces a different source_key, and
any translations attached to the old key don't carry over. They're orphaned, and the new text starts out
untranslated. For Afform authors this means rewording a form's static text is "translate this fresh," not
"update the existing translation."
How it's populated: Afform layouts¶
When an Afform is saved, Civi\Api4\Utils\AfformSaveTrait::saveTranslations() scans its layout HTML with
Civi\Afform\StringVisitor (which finds tag content, attribute values, and JSON sub-attributes worth
translating) and registers each distinct string as a TranslationSource row:
$context_key = CRM_Core_BAO_TranslationSource::createGuid(':::afform');
foreach ($strings as $value) {
$records[] = [
'source' => $value,
'source_key' => CRM_Core_BAO_TranslationSource::createGuid($value),
'context_key' => $context_key,
'entity' => 'afform',
];
}
TranslationSource::save(FALSE)->setRecords($records)->setMatch(['source_key'])->execute();
An admin (or a script) can then add a translation for one of those strings, matched by re-hashing the source text:
$sourceKey = CRM_Core_BAO_TranslationSource::createGuid('Thank you for registering!');
Translation::save(FALSE)
->addRecord([
'source_key' => $sourceKey,
'language' => 'fr_FR',
'status_id:name' => 'active',
'string' => 'Merci de vous être inscrit !',
])
->setMatch(['source_key', 'language', 'status_id'])
->execute();
How it's consulted: the bridge into ts()¶
Unlike the entity-keyed translations described above (which are read via setTranslationMode('fuzzy') on
an APIv4 get), TranslationSource translations are consulted from inside CRM_Core_I18n itself, as part
of ordinary string translation. Civi\Afform\Translator wraps every translatable string found in an Afform
layout in a call to ts() (technically _ts()) at render time. Inside
CRM_Core_I18n::crm_translate_raw(), before gettext ever runs, getTranslationReplacements() loads a
[$source => $string] lookup table for the active locale via
CRM_Core_BAO_TranslationSource::getTranslationSources($language) (a join of civicrm_translation to
civicrm_translation_source on source_key, filtered to active), and substitutes a match before
falling through to .mo-file lookups. If no TranslationSource match exists for the exact source text,
getTranslationReplacements() simply returns nothing and normal gettext lookup proceeds unaffected. So,
from a developer's point of view, these translations behave like an admin-editable, per-string override
sitting in front of gettext - specifically for the kind of text that gettext's .po/.mo pipeline can't
cover because it isn't a hard-coded string in the codebase.
Impact of the "Partial Locales" setting¶
The Partial Locales setting (partial_locales, under Administer > Localization > Language Settings;
enabled by default) controls which languages Civi\Core\Locale::negotiate() is willing to treat as the
nominal (communication) locale - and the nominal locale is what everything in this page keys off. It's
what the preferred language passed to setLanguage() gets negotiated into, and it's the starting point
CRM_Core_BAO_Translation::getTranslatedFieldsForRequest() re-negotiates from when applying the
fallback resolution (see the note on the entity-scoped re-negotiation under
Resolution order).
- Off: the nominal locale is restricted to the same set of "fully supported" locales used for
ts()- i.e. active entries in thelanguagesoption group that also have an installed Gettext language pack (CRM_Core_I18n::languages(FALSE), roughly "has anl10n/xx_YYdirectory on disk"). If you ask for a locale outside that set, negotiation doesn't get partway there - it falls straight through to the site's default language. - On (the default): the nominal locale can be any active entry in the
languagesoption group, even one with no installed language pack at all. This is "partial" because the services that genuinely require a.mofile or multilingual DB columns (ts(), and the legacy multi-lingual schema) still fall back to the nearest fully-supported locale for those purposes - but the nominal value itself, and anything keyed off it, uses the exact locale requested.
Why this matters for Translation records¶
The Translation entity doesn't need Gettext support for a language at all - it's just rows in a table,
matched by exact locale string. But it can only ever be found if something first negotiates a nominal
locale that matches it. That's where this setting bites:
Suppose a site wants to maintain civicrm_msg_template translations (or a contact's preferred_language)
in es_US specifically, but has never installed an es_US Gettext package (only es_MX ships with one).
With Partial Locales off, Locale::negotiate('es_US') can never resolve the nominal locale to es_US
at all - it isn't in the restricted "fully supported" list, so negotiation collapses straight to the site
default, and the es language-family fallback inside the Translation lookup never even
gets a chance to run. Any es_US (or es_ES, es_419, etc.) Translation records you've carefully
authored are simply unreachable via that contact/request. With Partial Locales on, es_US is accepted
as the nominal locale, ts() output quietly substitutes the closest supported UI language (e.g. es_MX),
and the Translation entity's own fallback chain runs normally against the real negotiated locale.
In short: if you're relying on Translation/WorkflowMessage content in a locale that isn't backed by an
installed Gettext language pack, Partial Locales must be on for that content to ever be served -
regardless of how many matching Translation rows exist.
See also¶
- Translation for Developers -
ts()/ Gettext, for CiviCRM's own UI strings. - Database localized fields and upgrades - the older, per-column multi-lingual schema.
- Workflow Messages - background on
WorkflowMessage::render().