Skip to content

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's preferred_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 in Civi\Core\Locale::detect()->nominal for the duration of the call. It may differ from the preferred language. For Translation lookups 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_ES and es_419 all belong to the es family. Fallback within a family is not simply "drop the regional suffix and try the bare language code" (there is no generic es locale 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 lcMessages setting).
Translation in the default language
A stored, active Translation record 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 a Translation record (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.

  1. An active translation in the exact (entity-scoped-re-negotiated) language.
  2. An active translation in another language from the same language family as the negotiated language (per the family's precedence order above).
  3. An active translation stored in the site's default language (not the raw value - see terminology above).
  4. 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, including NULL) 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. omits text from its result) when the resolved msg_text is empty, rather than rendering an empty string - that's a decision made by the message-rendering code, not a general rule of the Translation entity 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.

  1. Save a draft. A translator edits the subject/html/text for a language; the UI writes it as status_id:name => 'draft', leaving any existing active records 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.

  1. Preview it. The admin UI's preview dialog can render the draft revision directly (it just queries Translation::get() filtered to status_id:name = 'draft'), so reviewers see exactly what would go live, side-by-side with the current active translation, before committing to anything.

  2. Activate the draft. Once approved, the draft's field values are copied onto the active records (overwriting them) and the draft is removed. APIv4's replace action 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 the languages option group that also have an installed Gettext language pack (CRM_Core_I18n::languages(FALSE), roughly "has an l10n/xx_YY directory 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 languages option group, even one with no installed language pack at all. This is "partial" because the services that genuinely require a .mo file 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