docs/plans/5.4-6.0-custom-fields-rename.md
Status: Draft Target: 5.4 (API bridge) + 6.0 (model rename + visibility simplification) Depends on: None — 5.4 changes are serializer/prefix renames. 6.0 rename is part of the model rename wave. Author: Damian + Claude Last updated: 2026-04-08
Rename the "Metafields" system to "Custom Fields" and simplify the visibility model. The term "metafields" (borrowed from Shopify) creates confusion with the separate "metadata" system. "Custom Fields" is self-explanatory, merchant-friendly, and industry-standard (used by Vendure, commercetools, Salesforce, HubSpot, Jira, Linear).
In 6.0, simplify the three-way display_on (both/front_end/back_end) to a single boolean storefront_visible on definitions. This makes the two-system design razor-sharp:
The rename ships in two phases:
metadata JSON column is the developer escape hatch. Never exposed in Store API. Not going away. Not being consolidated into custom fields.public_metadata is deprecated — never was exposed in Store API, served same purpose as private_metadata. Single metadata accessor going forward. Deprecation warning when writing to public_metadata in 5.4, column dropped in 6.0.display_on (both/front_end/back_end) → storefront_visible (boolean, default: true). The front_end-only option was already excluded from MetafieldDefinition::DISPLAY and never made sense. This is a schema change and ships with the 6.0 model rename wave.display_on column stays until 6.0.metadata.| Platform | Unstructured (developer) | Structured (merchant/admin) | Naming clarity |
|---|---|---|---|
| Shopify | Metafields (no definition) | Metafields (with MetafieldDefinition) | Confusing — same name for both |
| Saleor | metadata / privateMetadata | Attributes (via ProductType) | Clear, but "Attributes" is ambiguous |
| Vendure | None built-in | Custom Fields (code config) | Clear |
| commercetools | Custom Objects (JSON) | Custom Fields (via Type + FieldDefinition) | Clear |
| Medusa v2 | metadata JSON property | Custom Modules (code-based) | Clear for metadata, heavy for structured |
| Spree (current) | metadata JSON column | Metafields (via MetafieldDefinition) | Confusing — "meta" prefix collision |
| Spree (proposed) | metadata JSON column | Custom Fields (via CustomFieldDefinition) | Clear |
| Platform | Visibility control | Private developer store |
|---|---|---|
| Shopify | access: { admin, storefront } per MetafieldDefinition | Metafields without definitions |
| Saleor | visibleInStorefront: boolean per Attribute | metadata / privateMetadata |
| Vendure | public: boolean per Custom Field (default: true) | No built-in unstructured store |
| commercetools | No visibility toggle on Custom Fields | Custom Objects (separate system) |
| Spree (current) | display_on: both/front_end/back_end | metadata JSON column |
| Spree (6.0) | storefront_visible: boolean (default: true) | metadata JSON column |
Our proposed storefront_visible boolean matches Vendure and Saleor exactly. The three-way display_on was overengineered — front_end-only (visible to customers but not admins) was already excluded and makes no sense.
Current display_on options on MetafieldDefinition:
both — Store API + Admin API (the common case)back_end — Admin API only (overlaps with metadata's purpose)front_end — already excluded from MetafieldDefinition::DISPLAY, never usedProblems:
back_end-only metafields overlap with metadata's purpose — merchants creating a "back_end" metafield are essentially using typed metadatapublic_metafields / private_metafields associations on models add complexityReplace display_on (string) with storefront_visible (boolean, default: true):
storefront_visible | Store API | Admin API | Use case |
|---|---|---|---|
true (default) | Yes | Yes | Product specs, customer-facing attributes |
false | No | Yes | Internal notes, compliance classification, supplier data |
The valid use case for admin-only structured fields (internal margin tier, supplier cost notes, compliance tags) is preserved via storefront_visible: false. These need schema enforcement and admin UI forms — metadata is wrong for them because it's schemaless and has no form inputs.
On Spree::CustomFieldDefinition (renamed from MetafieldDefinition):
storefront_visible boolean column (default: true, null: false)display_on IN ('both', 'front_end') → storefront_visible: true, display_on = 'back_end' → storefront_visible: falsedisplay_on columninclude Spree::DisplayOnOn Spree::HasCustomFields concern (renamed from Metafields) — simplify associations:
# Before
has_many :metafields, ...
has_many :public_metafields, -> { available_on_front_end }, ...
has_many :private_metafields, -> { available_on_back_end }, ...
# After
has_many :custom_fields, ...
has_many :storefront_custom_fields, -> { storefront_visible }, ...
# private_metafields removed entirely
Scopes on Spree::CustomField:
# Before
scope :available_on_front_end, -> { joins(:metafield_definition).merge(MetafieldDefinition.available_on_front_end) }
scope :available_on_back_end, -> { joins(:metafield_definition).merge(MetafieldDefinition.available_on_back_end) }
# After
scope :storefront_visible, -> { joins(:custom_field_definition).where(spree_custom_field_definitions: { storefront_visible: true }) }
The Store API surface is very small — metafields have no dedicated endpoints, only expand-based inclusion on 3 serializers:
| Layer | Files to change | Nature of change |
|---|---|---|
| Store API serializers | 4 | Rename expand param + response key + serializer class |
| Admin API serializers | 4 | Same, keep display_on as-is |
| Admin API controllers | 1 | Rename endpoint path |
| Dependencies registry | 1 | Rename keys |
| Prefix IDs (models) | 2 | Change prefix strings |
| Integration specs | 1 | Update expand param |
| Serializer unit tests | 2 | Update assertions |
| SDK types (auto-generated) | 4 | Regenerated |
| SDK Zod schemas (auto-generated) | 3 | Regenerated |
| OpenAPI spec (auto-generated) | 1 | Regenerated |
| Total | ~23 |
No migrations. No Store API controllers touch metafields — all access is via ?expand= on products, variants, and categories. Zero client SDK methods to rename (no CRUD endpoints for metafields in Store API).
| Model | Old prefix | New prefix |
|---|---|---|
Spree::Metafield | mf_ | cf_ |
Spree::MetafieldDefinition | mfdef_ | cfdef_ |
find_by_prefix_id! ignores the prefix during decoding (splits on _ and decodes Sqids portion), so old IDs with old prefixes still resolve correctly.
Rename expand parameter and response key from metafields to custom_fields:
# ProductSerializer — before
many :public_metafields,
key: :metafields,
resource: Spree.api.metafield_serializer,
if: proc { expand?('metafields') }
# ProductSerializer — after
many :public_metafields,
key: :custom_fields,
resource: Spree.api.custom_field_serializer,
if: proc { expand?('custom_fields') }
Same change on: VariantSerializer, CategorySerializer.
The underlying public_metafields association stays unchanged — it still filters by display_on internally. Only the API surface (expand param, response key, serializer class) changes.
New file api/app/serializers/spree/api/v3/custom_field_serializer.rb:
module Spree
module Api
module V3
class CustomFieldSerializer < BaseSerializer
typelize key: :string, label: :string, type: :string, value: :any
attributes :label, :type
attribute :key do |metafield|
metafield.full_key
end
attribute :value do |metafield|
metafield.serialize_value
end
end
end
end
end
Serializes Spree::Metafield model — same data, new file name and class name. Old MetafieldSerializer file is deleted.
label not name — Aligns with OptionType/OptionValue which also expose the human-readable display string as label. The programmatic identifier is key. MetafieldDefinition has a label alias on name (same delegating-method pattern as OptionType's label → presentation).
Rename expand parameter and response key. Keep display_on until 6.0:
# Admin::ProductSerializer — before
many :metafields,
resource: Spree.api.admin_metafield_serializer,
if: proc { expand?('metafields') }
# Admin::ProductSerializer — after
many :metafields,
key: :custom_fields,
resource: Spree.api.admin_custom_field_serializer,
if: proc { expand?('custom_fields') }
Same change on: Admin::VariantSerializer, Admin::CategorySerializer.
New file api/app/serializers/spree/api/v3/admin/custom_field_serializer.rb:
module Spree
module Api
module V3
module Admin
class CustomFieldSerializer < V3::CustomFieldSerializer
typelize display_on: :string
attributes :display_on
end
end
end
end
end
In 6.0, display_on becomes storefront_visible: boolean.
Rename metafield_type → field_type in serializer output. Keep display_on until 6.0:
# Before
attributes :display_on, :metafield_type
# After
attributes :display_on
attribute :field_type do |definition|
definition.metafield_type
end
# Before
GET/POST /api/v3/admin/metafield_definitions
GET/PATCH/DELETE /api/v3/admin/metafield_definitions/:id
# After
GET/POST /api/v3/admin/custom_field_definitions
GET/PATCH/DELETE /api/v3/admin/custom_field_definitions/:id
Controller file rename: admin/metafield_definitions_controller.rb → admin/custom_field_definitions_controller.rb. Internally still uses Spree::MetafieldDefinition model.
# Before
metafield_serializer: 'Spree::Api::V3::MetafieldSerializer',
admin_metafield_serializer: 'Spree::Api::V3::Admin::MetafieldSerializer',
# After — add new keys, remove old
custom_field_serializer: 'Spree::Api::V3::CustomFieldSerializer',
admin_custom_field_serializer: 'Spree::Api::V3::Admin::CustomFieldSerializer',
custom_field_definition_serializer: 'Spree::Api::V3::Admin::CustomFieldDefinitionSerializer',
// Before
interface StoreMetafield { id: string; name: string; type: string; key: string; value: any }
client.products.list({ expand: ['metafields'] })
product.metafields
// After
interface StoreCustomField { id: string; label: string; type: string; key: string; value: any }
client.products.list({ expand: ['custom_fields'] })
product.custom_fields
Generated types rename: StoreMetafield → StoreCustomField, AdminMetafield → AdminCustomField, AdminMetafieldDefinition → AdminCustomFieldDefinition. The name field is renamed to label to align with OptionType/OptionValue naming.
Add deprecation warning in the Spree::Metadata concern when public_metadata= is called:
def public_metadata=(value)
Spree::Deprecation.warn(
"public_metadata is deprecated. Use metadata instead. " \
"For customer-visible structured data, use metafields with display_on: 'both'."
)
super
end
Update docs to advise using only the metadata accessor.
Spree::Metafield, Spree::MetafieldDefinition)spree_metafields, spree_metafield_definitions)display_on column stays)Spree::Metafields::ShortText, etc.)Spree::Metafields, Spree::Metadata)has_many :metafields, has_many :public_metafields)Spree.metafields configurationFull internal rename. Part of the 6.0 model rename wave alongside Shipment→Fulfillment, ShippingMethod→DeliveryMethod, etc.
| Current | 6.0 | Notes |
|---|---|---|
Spree::Metafield | Spree::CustomField | |
Spree::MetafieldDefinition | Spree::CustomFieldDefinition | |
Spree::Metafields::ShortText | Spree::CustomFields::ShortText | STI subclass |
Spree::Metafields::LongText | Spree::CustomFields::LongText | STI subclass |
Spree::Metafields::RichText | Spree::CustomFields::RichText | STI subclass |
Spree::Metafields::Number | Spree::CustomFields::Number | STI subclass |
Spree::Metafields::Boolean | Spree::CustomFields::Boolean | STI subclass |
Spree::Metafields::Json | Spree::CustomFields::Json | STI subclass |
Spree::Metafields concern | Spree::HasCustomFields | |
spree_metafields table | spree_custom_fields | |
spree_metafield_definitions table | spree_custom_field_definitions | |
metafield_definition_id FK | custom_field_definition_id FK | |
metafield_type column | field_type column | On definitions table |
display_on column (string) | storefront_visible column (boolean) | On definitions table |
Spree.metafields config | Spree.custom_fields config | |
set_metafield / get_metafield | set_custom_field / get_custom_field | Instance methods |
has_metafield? | has_custom_field? | Instance method |
with_metafield_key scope | with_custom_field_key scope | |
with_metafield_key_value scope | with_custom_field_key_value scope | |
public_metafields assoc | storefront_custom_fields assoc | |
private_metafields assoc | removed | |
Admin serializer display_on: string | storefront_visible: boolean | |
Definition name column | label column | 5.4 adds alias, 6.0 renames column |
public_metadata column from all tablesprivate_metadata → metadata in the databaseSpree::Metadata concern to single metadata column with no alias indirectionspree_metafields → spree_custom_fields, spree_metafield_definitions → spree_custom_field_definitionsmetafield_definition_id → custom_field_definition_id, metafield_type → field_typedisplay_on with storefront_visible:
add_column :spree_custom_field_definitions, :storefront_visible, :boolean, default: true, null: false
# Data migration: back_end → false, both/front_end → true
remove_column :spree_custom_field_definitions, :display_on
type column values: Spree::Metafields::ShortText → Spree::CustomFields::ShortText, etc.public_metadata, rename private_metadata → metadata on all tablesmetafield.custom.id → custom_field.custom.id
expand: ['metafields'] → expand: ['custom_fields'] in all API callsStoreMetafield → StoreCustomFieldmf_ → cf_, mfdef_ → cfdef_ (lookup still works with old prefixes)metafields → custom_fieldsSpree::Metafield → Spree::CustomFieldinclude Spree::Metafields → include Spree::HasCustomFieldsset_metafield → set_custom_field, etc.Spree.metafields → Spree.custom_fields configuration referencespublic_metadata / private_metadata with single metadatadisplay_on with storefront_visible booleancustom_fields naming after the 5.4 bridge ships.custom_fields / custom_field_definitions naming after the 5.4 bridge ships.custom_fields after this ships.metafield naming until 6.0.public_metadata — use metadata accessor instead. public_metadata will emit deprecation warnings in 5.4.5.4-store-api-bridges.md — same bridge pattern (API naming changed in 5.4, models renamed in 6.0)5.4-6.0-product-media-system.md — images→media bridge6.0-product-types.md — ProductType gains has_many :custom_field_definitions (terminology updates needed)5.4-search-provider.md — CustomFieldDefinition.filterable for faceting (terminology updates needed)decisions.md — "Consolidate metadata" entry confirms two-system design