docs/plans/5.4-metafield-translations.md
Status: Draft Target: Spree 5.4 Depends on: Metafields system (shipped), TranslatableResource / Mobility infrastructure Author: Damian Last updated: 2026-04-08
Metafields and MetafieldDefinitions do not support translations. Products, Options, and Categories are translatable via Mobility, but custom field values and definition labels are locale-unaware. This plan adds translation support for MetafieldDefinition names (admin labels) and Metafield values (text types only), using Spree's existing Mobility + per-entity translation table pattern.
LocaleShortText / LocaleLongText (Vendure-style). Instead, all text types gain translation support automatically. Mobility's column_fallback handles "no translation available, use base value" gracefully.name is translatable — The admin-facing label for a custom field definition needs translation for multi-language admin UIs.6.0-rich-text-descriptions.md plan to drop ActionText storage. Translated rich text goes in spree_metafield_translations.value as HTML.spree_metafield_translations table serves all STI types, consistent with how the base spree_metafields table works.translatable? method on Metafield — Each STI subclass declares whether it supports translations. Controllers and serializers use this to decide behavior.nameclass Spree::MetafieldDefinition < Spree.base_class
include Spree::TranslatableResource
TRANSLATABLE_FIELDS = %i[name].freeze
translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
end
value (text types only)class Spree::Metafield < Spree.base_class
include Spree::TranslatableResource
TRANSLATABLE_FIELDS = %i[value].freeze
translates(*TRANSLATABLE_FIELDS, column_fallback: true)
# column_fallback: true always — base value is the default-locale value
# Override in subclasses
def translatable?
false
end
end
module Spree::Metafields
class ShortText < Spree::Metafield
def translatable?
true
end
end
class LongText < Spree::Metafield
def translatable?
true
end
end
class RichText < Spree::Metafield
def translatable?
true
end
# For translations, store HTML directly in the translation table's
# value column — do NOT use ActionText for translated values.
# The base (default locale) value continues to use ActionText
# until the 6.0 ActionText removal.
end
end
Non-text types (Number, Boolean, Json) inherit the default translatable? # => false and never touch the translation table.
| Column | Type | Constraints |
|---|---|---|
id | bigint | PK |
spree_metafield_id | references | not null |
locale | string | not null |
value | text | |
created_at | datetime | |
updated_at | datetime |
Unique index: [spree_metafield_id, locale]
| Column | Type | Constraints |
|---|---|---|
id | bigint | PK |
spree_metafield_definition_id | references | not null |
locale | string | not null |
name | string | |
created_at | datetime | |
updated_at | datetime |
Unique index: [spree_metafield_definition_id, locale]
fr → en, a German store de → en.translatable? check. The Mobility translates declaration on the base class only activates the join/lookup when the type supports it.Custom fields are already serialized via CustomFieldSerializer. The current locale (from x-spree-locale header / Spree::Current.locale) determines which translation is returned. No API shape change needed — the value field simply returns the translated value for text types.
{
"custom_fields": [
{
"key": "specs.care_instructions",
"label": "Entretien",
"type": "short_text",
"value": "Lavage a 30C"
},
{
"key": "specs.weight_grams",
"label": "Poids",
"type": "number",
"value": 250
}
]
}
Definition label (alias for name) is also translated via Mobility (the definition's name method already delegates through Mobility once we add the concern, and label delegates to name).
Admin serializers expose a translatable flag and translations hash for managing translations across locales:
Read (GET):
{
"key": "specs.care_instructions",
"label": "Care Instructions",
"type": "short_text",
"value": "Machine wash at 30C",
"translatable": true,
"translations": {
"fr": { "value": "Lavage a 30C" },
"de": { "value": "Maschinenwaesche bei 30C" }
}
}
Non-translatable types omit translations or return an empty object:
{
"key": "specs.weight_grams",
"label": "Weight (grams)",
"type": "number",
"value": 250,
"translatable": false
}
Write (PATCH):
{
"translations": {
"fr": { "value": "Lavage a 30C" }
}
}
{
"namespace": "specs",
"key": "care_instructions",
"label": "Care Instructions",
"label_translations": {
"fr": "Entretien",
"de": "Pflegehinweise"
}
}
module Spree::Api::V3
class CustomFieldSerializer < BaseSerializer
# value already reads from Mobility (current locale with fallback)
# label already reads from definition (which is now translatable)
# No changes needed — Mobility handles locale resolution transparently
end
end
module Spree::Api::V3::Admin
class CustomFieldSerializer < V3::CustomFieldSerializer
typelize translatable: :boolean
attribute :translatable do |metafield|
metafield.translatable?
end
attribute :translations do |metafield|
next {} unless metafield.translatable?
metafield.translations.each_with_object({}) do |translation, hash|
hash[translation.locale] = { value: translation.value }
end
end
end
end
The base (default locale) RichText value continues to use ActionText via has_rich_text. Translated values for non-default locales are stored as HTML strings in spree_metafield_translations.value.
When reading a RichText metafield:
body.to_s (existing behavior)When writing a RichText translation:
value columnThis asymmetry is temporary — the 6.0-rich-text-descriptions.md plan will move all rich text to plain HTML columns, at which point RichText metafields will be fully symmetric.
Extend the existing ProductTranslationsController pattern to include metafield translation coverage:
5.4-centralized-translations-admin.md plan)class CreateSpreeMetafieldTranslations < ActiveRecord::Migration[7.2]
def change
create_table :spree_metafield_translations do |t|
t.references :spree_metafield, null: false
t.string :locale, null: false
t.text :value
t.timestamps
end
add_index :spree_metafield_translations,
[:spree_metafield_id, :locale],
unique: true,
name: 'index_spree_metafield_translations_uniqueness'
create_table :spree_metafield_definition_translations do |t|
t.references :spree_metafield_definition, null: false
t.string :locale, null: false
t.string :name
t.timestamps
end
add_index :spree_metafield_definition_translations,
[:spree_metafield_definition_id, :locale],
unique: true,
name: 'index_spree_mf_def_translations_uniqueness'
end
end
TranslatableResource to MetafieldDefinition with translates :nameTranslatableResource to Metafield with translates :value, column_fallback: truetranslatable? method to base Metafield (returns false)translatable? in ShortText, LongText, RichText (returns true)CustomFieldSerializer to include translatable and translationsMetafieldDefinitionSerializer to include label_translationstranslations hash in permitted params for text-type metafieldslabel_translations hash in permitted params for definitionstranslatable flag and translations hashLocaleShortText etc.). The translatable? method on existing types is the mechanism.translatable? — default to false unless the type is text-based.description be added to MetafieldDefinition? Currently definitions only have name. A translatable description field could help merchants understand what each custom field is for. Low priority but worth considering.5.4-centralized-translations-admin.md), or be a separate flow?6.0-rich-text-descriptions.md — ActionText removal plan (affects RichText metafield storage)5.4-centralized-translations-admin.md — Centralized translations admin page5.4-6.0-custom-fields-rename.md — Metafields → Custom Fields renamelocaleString/localeText — type-level translation decision, column on translation table