Back to Spree

Metafields → Custom Fields Rename

docs/plans/5.4-6.0-custom-fields-rename.md

5.4.218.9 KB
Original Source

Metafields → Custom Fields Rename

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

Summary

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:

  • Custom Fields — public structured data (storefront-visible by default, toggle off for admin-only)
  • Metadata — private developer-owned data (never exposed to customers)

The rename ships in two phases:

  1. 5.4 — Bridge the Store API surface: prefix IDs, expand parameter, serializer response keys, Admin API endpoint paths. Pure serializer-layer changes, no schema changes.
  2. 6.0 — Rename models, tables, concerns, STI types, visibility simplification, and all internal code.

Key Decisions (do not deviate without discussion)

  • "Custom Fields" not "Attributes" — avoids ambiguity with built-in attributes (name, price, SKU are also "attributes"). Aligns with Vendure, commercetools, and non-ecommerce SaaS conventions.
  • Metadata is permanent and privatemetadata 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.
  • Visibility simplifies to a boolean in 6.0display_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.
  • 5.4 is a clean break on API surface — no aliases, no deprecation on API naming. Same approach as the 5.4-store-api-bridges plan. The underlying display_on column stays until 6.0.
  • Two systems, clear boundary:
    • Metadata — for machines. Schemaless JSON. Developer-owned. Private. Think: Stripe's metadata.
    • Custom Fields — for humans. Typed, validated, schema-defined. Merchant-managed. Public by default, with opt-out for admin-only fields. Think: Salesforce custom fields.

Industry Comparison

Naming

PlatformUnstructured (developer)Structured (merchant/admin)Naming clarity
ShopifyMetafields (no definition)Metafields (with MetafieldDefinition)Confusing — same name for both
Saleormetadata / privateMetadataAttributes (via ProductType)Clear, but "Attributes" is ambiguous
VendureNone built-inCustom Fields (code config)Clear
commercetoolsCustom Objects (JSON)Custom Fields (via Type + FieldDefinition)Clear
Medusa v2metadata JSON propertyCustom Modules (code-based)Clear for metadata, heavy for structured
Spree (current)metadata JSON columnMetafields (via MetafieldDefinition)Confusing — "meta" prefix collision
Spree (proposed)metadata JSON columnCustom Fields (via CustomFieldDefinition)Clear

Visibility on structured fields

PlatformVisibility controlPrivate developer store
Shopifyaccess: { admin, storefront } per MetafieldDefinitionMetafields without definitions
SaleorvisibleInStorefront: boolean per Attributemetadata / privateMetadata
Vendurepublic: boolean per Custom Field (default: true)No built-in unstructured store
commercetoolsNo visibility toggle on Custom FieldsCustom Objects (separate system)
Spree (current)display_on: both/front_end/back_endmetadata 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.

Design Details

Visibility Simplification (6.0)

Why drop the three-way split?

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 used

Problems:

  1. back_end-only metafields overlap with metadata's purpose — merchants creating a "back_end" metafield are essentially using typed metadata
  2. Three-way enum is harder to explain than a boolean
  3. The public_metafields / private_metafields associations on models add complexity

New model (6.0)

Replace display_on (string) with storefront_visible (boolean, default: true):

storefront_visibleStore APIAdmin APIUse case
true (default)YesYesProduct specs, customer-facing attributes
falseNoYesInternal 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.

Model changes (6.0)

On Spree::CustomFieldDefinition (renamed from MetafieldDefinition):

  • Add storefront_visible boolean column (default: true, null: false)
  • Data migration: display_on IN ('both', 'front_end')storefront_visible: true, display_on = 'back_end'storefront_visible: false
  • Remove display_on column
  • Remove include Spree::DisplayOn

On Spree::HasCustomFields concern (renamed from Metafields) — simplify associations:

ruby
# 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:

ruby
# 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 }) }

Phase 1: 5.4 API Bridge

Scope assessment

The Store API surface is very small — metafields have no dedicated endpoints, only expand-based inclusion on 3 serializers:

LayerFiles to changeNature of change
Store API serializers4Rename expand param + response key + serializer class
Admin API serializers4Same, keep display_on as-is
Admin API controllers1Rename endpoint path
Dependencies registry1Rename keys
Prefix IDs (models)2Change prefix strings
Integration specs1Update expand param
Serializer unit tests2Update assertions
SDK types (auto-generated)4Regenerated
SDK Zod schemas (auto-generated)3Regenerated
OpenAPI spec (auto-generated)1Regenerated
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).

Prefix ID changes

ModelOld prefixNew prefix
Spree::Metafieldmf_cf_
Spree::MetafieldDefinitionmfdef_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.

Store API serializer changes

Rename expand parameter and response key from metafields to custom_fields:

ruby
# 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.

Store API CustomFieldSerializer (rename from MetafieldSerializer)

New file api/app/serializers/spree/api/v3/custom_field_serializer.rb:

ruby
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 labelpresentation).

Admin API serializer changes

Rename expand parameter and response key. Keep display_on until 6.0:

ruby
# 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:

ruby
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.

Admin API CustomFieldDefinitionSerializer

Rename metafield_typefield_type in serializer output. Keep display_on until 6.0:

ruby
# Before
attributes :display_on, :metafield_type

# After
attributes :display_on
attribute :field_type do |definition|
  definition.metafield_type
end

Admin API endpoint renames

# 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.rbadmin/custom_field_definitions_controller.rb. Internally still uses Spree::MetafieldDefinition model.

Dependencies registration

ruby
# 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',

SDK / TypeScript type changes

typescript
// 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: StoreMetafieldStoreCustomField, AdminMetafieldAdminCustomField, AdminMetafieldDefinitionAdminCustomFieldDefinition. The name field is renamed to label to align with OptionType/OptionValue naming.

public_metadata deprecation (5.4)

Add deprecation warning in the Spree::Metadata concern when public_metadata= is called:

ruby
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.

What does NOT change in 5.4

  • Model names (Spree::Metafield, Spree::MetafieldDefinition)
  • Table names (spree_metafields, spree_metafield_definitions)
  • Database schema (no migrations — display_on column stays)
  • STI type column values (Spree::Metafields::ShortText, etc.)
  • Concern names (Spree::Metafields, Spree::Metadata)
  • Association names on models (has_many :metafields, has_many :public_metafields)
  • Internal Spree.metafields configuration
  • Event names
  • Admin UI (uses internal model layer, not API naming)
  • CSV export headers (internal, not API surface)

Phase 2: 6.0 Model Rename + Visibility Simplification

Full internal rename. Part of the 6.0 model rename wave alongside Shipment→Fulfillment, ShippingMethod→DeliveryMethod, etc.

Current6.0Notes
Spree::MetafieldSpree::CustomField
Spree::MetafieldDefinitionSpree::CustomFieldDefinition
Spree::Metafields::ShortTextSpree::CustomFields::ShortTextSTI subclass
Spree::Metafields::LongTextSpree::CustomFields::LongTextSTI subclass
Spree::Metafields::RichTextSpree::CustomFields::RichTextSTI subclass
Spree::Metafields::NumberSpree::CustomFields::NumberSTI subclass
Spree::Metafields::BooleanSpree::CustomFields::BooleanSTI subclass
Spree::Metafields::JsonSpree::CustomFields::JsonSTI subclass
Spree::Metafields concernSpree::HasCustomFields
spree_metafields tablespree_custom_fields
spree_metafield_definitions tablespree_custom_field_definitions
metafield_definition_id FKcustom_field_definition_id FK
metafield_type columnfield_type columnOn definitions table
display_on column (string)storefront_visible column (boolean)On definitions table
Spree.metafields configSpree.custom_fields config
set_metafield / get_metafieldset_custom_field / get_custom_fieldInstance methods
has_metafield?has_custom_field?Instance method
with_metafield_key scopewith_custom_field_key scope
with_metafield_key_value scopewith_custom_field_key_value scope
public_metafields assocstorefront_custom_fields assoc
private_metafields assocremoved
Admin serializer display_on: stringstorefront_visible: boolean
Definition name columnlabel column5.4 adds alias, 6.0 renames column

6.0 Metadata cleanup

  • Drop public_metadata column from all tables
  • Rename private_metadatametadata in the database
  • Simplify Spree::Metadata concern to single metadata column with no alias indirection

6.0 Migrations

  1. Rename tables: spree_metafieldsspree_custom_fields, spree_metafield_definitionsspree_custom_field_definitions
  2. Rename columns: metafield_definition_idcustom_field_definition_id, metafield_typefield_type
  3. Replace display_on with storefront_visible:
    ruby
    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
    
  4. Update STI type column values: Spree::Metafields::ShortTextSpree::CustomFields::ShortText, etc.
  5. Drop public_metadata, rename private_metadatametadata on all tables

6.0 CSV export headers

metafield.custom.idcustom_field.custom.id

Migration Path

For storefront developers (5.4)

  1. Update SDK to new version
  2. Change expand: ['metafields']expand: ['custom_fields'] in all API calls
  3. Update TypeScript interfaces: StoreMetafieldStoreCustomField
  4. Prefix IDs change from mf_cf_, mfdef_cfdef_ (lookup still works with old prefixes)
  5. Response key changes from metafieldscustom_fields

For extension/plugin developers (6.0)

  1. Update any references to Spree::MetafieldSpree::CustomField
  2. Update concern includes: include Spree::Metafieldsinclude Spree::HasCustomFields
  3. Update method calls: set_metafieldset_custom_field, etc.
  4. Update Spree.metafieldsSpree.custom_fields configuration references
  5. Replace public_metadata / private_metadata with single metadata
  6. Replace display_on with storefront_visible boolean

Constraints on Current Work

  • All new Store API code must use custom_fields naming after the 5.4 bridge ships.
  • All new Admin API code must use custom_fields / custom_field_definitions naming after the 5.4 bridge ships.
  • SDK examples and docs must use custom_fields after this ships.
  • Internal model code can still use metafield naming until 6.0.
  • Do not use public_metadata — use metadata accessor instead. public_metadata will emit deprecation warnings in 5.4.

Open Questions

  • Admin UI rename timing — Should the Admin UI labels/navigation change from "Metafield Definitions" to "Custom Field Definitions" in 5.4 (purely cosmetic, i18n strings) or defer to 6.0? Leaning toward 5.4 — helps merchants see consistent terminology.

References

  • Prior art: 5.4-store-api-bridges.md — same bridge pattern (API naming changed in 5.4, models renamed in 6.0)
  • Prior art: 5.4-6.0-product-media-system.md — images→media bridge
  • 6.0-product-types.md — ProductType gains has_many :custom_field_definitions (terminology updates needed)
  • 5.4-search-provider.mdCustomFieldDefinition.filterable for faceting (terminology updates needed)
  • decisions.md — "Consolidate metadata" entry confirms two-system design