Back to Spree

Metafields → Custom Fields Rename

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

5.5.034.9 KB
Original Source

Metafields → Custom Fields Rename

Status: 5.4 serializer bridge ✓; 5.5 alias classes (Spree::CustomField, Spree::CustomFieldDefinition) ✓; 6.0 model/table rename pending Target: 5.4 (Store API serializer bridge ✓) + 5.5 (Admin CRUD API + SDK + Spree::CustomField constant alias ✓) + 6.0 (model/table rename + visibility simplification + STI rename) Depends on: None — 5.4/5.5 changes are additive. 6.0 rename is part of the model rename wave. Author: Damian + Claude Last updated: 2026-05-20

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 three phases:

  1. 5.4 (shipped) — Bridge the Store API surface: prefix IDs, expand parameter, serializer response keys, Admin API endpoint paths. Pure serializer-layer changes, no schema changes.
  2. 5.5 (shipping) — Admin CRUD API + SDK + Spree::CustomField / Spree::CustomFieldDefinition constant aliases so controllers, serializers, and 5.5+ extensions reference the model by its 6.0-bound name. Plus model-level alias_attributes for the API field names (custom_field_definition_id, field_type, label, storefront_visible). No schema changes; no deprecation warnings yet.
  3. 6.0 — Rename models, tables, concerns, STI types, visibility simplification, and all internal code. Drop the constant aliases and the alias_attribute shims.

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.
  • 5.5 keeps the API surface stable AND introduces Spree::CustomField/Spree::CustomFieldDefinition as constant aliases for the existing models. This lets controllers, serializers, and extensions reference the 6.0-bound name today. No schema changes, no deprecation warnings — the rename is a constant-level shim until 6.0 ships the actual class/table rename.
  • 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 changeStatus
Store API serializers4Rename expand param + response key + serializer class✅ shipped
Admin API serializers4Same, keep display_on as-is✅ shipped
Admin API controllers1Rename endpoint path⏳ pending — see CRUD section
Dependencies registry1Rename keys✅ shipped
Prefix IDs (models)2Change prefix strings✅ shipped
Integration specs1Update expand param✅ shipped
Serializer unit tests2Update assertions✅ shipped
SDK types (auto-generated)4Regenerated✅ shipped
SDK Zod schemas (auto-generated)3Regenerated✅ shipped
OpenAPI spec (auto-generated)1Regenerated✅ shipped
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).

CRUD API for the Admin ships separately as part of 5.4 — see Custom Fields CRUD API + SDK below.

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 1.5: 5.5 Constant Aliases + alias_attributes

Two thin shims ship in 5.5 so that controllers, serializers, and any 5.5+ extension code can use the new names today without waiting for the 6.0 model/table rename:

Constant aliases (model-level)

ruby
# spree/core/app/models/spree/custom_field.rb
module Spree
  CustomField = Metafield
end

# spree/core/app/models/spree/custom_field_definition.rb
module Spree
  CustomFieldDefinition = MetafieldDefinition
end

A constant alias means Spree::CustomField.equal?(Spree::Metafield) is true — same class, two names. STI works (the parent's type column still resolves to Spree::Metafields::ShortText etc.), find_by_prefix_id! works, all associations work. This explicitly is not a subclass — earlier exploration confirmed that subclassing fights STI (Spree::Metafields::ShortText would not be < Spree::CustomField).

What you get:

  • Controllers reference Spree::CustomField / Spree::CustomFieldDefinition directly
  • New extensions don't write Spree::Metafield in their own code
  • 6.0 cleanup: delete the alias files, replace each reference's Metafield with CustomField (mechanical), do the table/class rename in one wave

What you don't get (intentional):

  • model_name.name still says 'Spree::Metafield' — URL helpers, error messages, I18n keys all use the legacy name. That's a 6.0 concern.
  • Spree::Metafields::ShortText STI subclasses keep their namespace until 6.0.
  • No deprecation warnings on Spree::Metafield references — 5.5 is a soft introduction, not a deprecation.

alias_attribute for API field names

The model exposes the API-facing field names as alias_attributes pointing at the existing columns:

ruby
class Spree::Metafield
  alias_attribute :custom_field_definition_id, :metafield_definition_id
end

class Spree::MetafieldDefinition
  alias_attribute :label, :name
  alias_attribute :field_type, :metafield_type
  # `display_on` is an enum string ('both'/'front_end'/'back_end'); expose
  # `storefront_visible` as a boolean accessor.
  def storefront_visible; available_on_front_end?; end
  def storefront_visible=(value)
    self.display_on = ActiveModel::Type::Boolean.new.cast(value) ? 'both' : 'back_end'
  end
end

The Spree::PrefixedId concern automatically resolves prefixed-ID values through alias_attribute (it walks attribute_aliases when assigning), so a controller can do params.permit(:custom_field_definition_id, :value) and have AR's assign_attributes write to the correct column with the prefixed ID decoded.

This is the entire alias surface. Keep controllers thin permit-and-pass; the API ↔ DB rename lives in the model.

field_type tokens (5.5)

Both 5.4 serializers shipped Spree::Metafields::ShortText-style class strings via the AR type column and the metafield_type column. That leaks Ruby internals across both Store and Admin APIs and breaks every SDK consumer in 6.0 when the STI namespace renames to Spree::CustomFields::*. Every major competitor (Shopify, Saleor, Vendure, commercetools) exposes abstract tokens, not class names — Shopify migrated explicitly in 2022.

5.5 introduces a token vocabulary for custom-field types:

API tokenInternal STI class
short_textSpree::Metafields::ShortText
long_textSpree::Metafields::LongText
rich_textSpree::Metafields::RichText
numberSpree::Metafields::Number
booleanSpree::Metafields::Boolean
jsonSpree::Metafields::Json

Implementation:

ruby
class Spree::Metafield
  TYPE_TOKENS = { 'short_text' => 'Spree::Metafields::ShortText', ... }.freeze
  TYPE_CLASS_TO_TOKEN = TYPE_TOKENS.invert.freeze
  FIELD_TYPE_TS_UNION = TYPE_TOKENS.keys.map { |t| "'#{t}'" }.join(' | ').freeze

  def field_type
    TYPE_CLASS_TO_TOKEN[self[:type]] || self[:type]
  end
end

class Spree::MetafieldDefinition
  def field_type
    Spree::Metafield::TYPE_CLASS_TO_TOKEN[metafield_type] || metafield_type
  end

  def field_type=(value)
    self.metafield_type = Spree::Metafield::TYPE_TOKENS[value.to_s] || value
  end
end

Reads always emit the token. Writes accept either the token (short_text) or the legacy class string (Spree::Metafields::ShortText) for back-compat — external integrations that wrote the old form keep working. Fall-through preserves plugin-defined types (e.g. MyExt::Metafields::Color) until a registration API lands in 6.0.

Wire-format changes in 5.5 (all additive — no breaking changes):

  • Store API Spree::Api::V3::CustomFieldSerializer — adds field_type (token) alongside the legacy type (class string). Both ship.
  • Admin API Admin::CustomFieldSerializer — same: adds field_type, keeps type.
  • Admin API Admin::CustomFieldDefinitionSerializerfield_type value changed from class string → token (field name was already field_type). This is the one shape change consumers must adapt to.

Removal of the legacy type field on CustomFieldSerializer ships in a follow-up minor (deprecation removed once consumers have migrated to field_type).

The SDK type field_type becomes a discriminated union ('short_text' | 'long_text' | 'rich_text' | 'number' | 'boolean' | 'json') so storefront and admin consumers get type-narrowable values.

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

Custom Fields CRUD API + SDK (5.4)

The 5.4 serializer bridge exposes custom fields as a read-only ?expand=custom_fields payload, but no CRUD endpoints exist. The admin SPA's <CustomFieldsDrawer> (per 6.0-admin-spa.md Tier 2) is gated on these endpoints. This section scopes them.

Why now

29 models include Spree::Metafields today (Variant, Order, Product, Customer, Category, OptionType, Taxon, Shipment, Payment, Refund, GiftCard, StoreCredit, Address, LineItem, Store, Promotion, ShippingMethod, TaxRate, StockItem, StockTransfer, CustomerReturn, CreditCard, PaymentMethod, PaymentSource, NewsletterSubscriber, PaymentSession, PaymentSetupSession, Asset, Taxonomy). Every one of those resources benefits from in-admin CRUD. Building the API once + SDK once means every detail page in the SPA can opt into custom fields with a single <PageHeader> prop.

Routing — Rails route concern

Two routing surfaces because the data is two-tiered:

  • Custom fields are per-resource-instance (this product has a material: "wool" value). They mount under each parent that includes Spree::Metafields.
  • Custom field definitions are per-resource-type (every Spree::Product shares the same definitions). They live at a flat endpoint, filtered by ?resource_type=.
ruby
# spree/api/config/routes.rb (admin namespace)

concern :custom_fieldable do
  resources :custom_fields, controller: 'custom_fields'
end

namespace :admin do
  resources :custom_field_definitions  # flat — definitions are per resource type

  resources :products,    concerns: :custom_fieldable do; ...; end
  resources :variants,    concerns: :custom_fieldable, only: %i[index show]
  resources :orders,      concerns: :custom_fieldable do; ...; end
  resources :customers,   concerns: :custom_fieldable do; ...; end
  resources :categories,  concerns: :custom_fieldable, only: %i[index show]
  resources :option_types, concerns: :custom_fieldable
  # …extend to remaining `Spree::Metafields`-bearing parents incrementally
end

URLs:

# Per-instance custom field values (nested)
GET    /api/v3/admin/products/prod_xxx/custom_fields
POST   /api/v3/admin/products/prod_xxx/custom_fields
PATCH  /api/v3/admin/products/prod_xxx/custom_fields/cf_xxx
DELETE /api/v3/admin/products/prod_xxx/custom_fields/cf_xxx

# Per-type custom field definitions (flat, filter by resource_type)
GET    /api/v3/admin/custom_field_definitions?resource_type=Spree::Product
POST   /api/v3/admin/custom_field_definitions
PATCH  /api/v3/admin/custom_field_definitions/cfdef_xxx
DELETE /api/v3/admin/custom_field_definitions/cfdef_xxx

Why the concern over polymorphic /custom_fields?owner_type=...&owner_id=...: consistent with the rest of the Admin API (orders/items, customers/addresses, products/media all use nested resources). Keeps URLs human-readable. Plugin parents apply the concern in their own routes file — zero core changes when a plugin adds a new metafield-bearing model.

Controllers

One Admin::CustomFieldsController and one Admin::CustomFieldDefinitionsController, both inheriting from the existing Spree::Api::V3::Admin::ResourceController. They infer the parent class from whichever :<parent>_id param is present in the route, then scope all reads/writes through the parent's metafields association (or metafield_definitions for definitions). Authorization uses CanCanCan against the parent — authorize! :update, parent for create/update/delete, authorize! :show, parent for index/show.

ruby
module Spree::Api::V3::Admin
  class CustomFieldsController < ResourceController
    protected

    def model_class
      Spree::Metafield
    end

    def serializer_class
      Spree.api.admin_custom_field_serializer
    end

    def scope
      parent.metafields
    end

    # Inferred from the first `:<parent>_id` param present.
    def parent
      @parent ||= load_parent_from_route
    end

    # Permits only the API-facing fields; `key` and `type` are derived from
    # the linked CustomFieldDefinition via the model's before_validation
    # callback. Permitting `:type` would let API clients pick STI subclasses
    # directly — unsafe.
    #
    # `custom_field_definition_id` is an `alias_attribute` on `Spree::Metafield`
    # for the internal `metafield_definition_id` column. AR's `assign_attributes`
    # also resolves the prefixed-ID through the alias automatically.
    def permitted_params
      params.permit(:custom_field_definition_id, :value)
    end
  end
end

Naming aliases live in the models, not in controllers. The controller stays a thin permit-and-pass layer; the API ↔ DB column rename is encoded once on Spree::Metafield and Spree::MetafieldDefinition:

ruby
class Spree::Metafield
  alias_attribute :custom_field_definition_id, :metafield_definition_id
end

class Spree::MetafieldDefinition
  alias_attribute :label, :name
  alias_attribute :field_type, :metafield_type

  # `display_on` is an enum string ('both'/'front_end'/'back_end'); expose a
  # boolean as the API surface.
  def storefront_visible
    available_on_front_end?
  end

  def storefront_visible=(value)
    self.display_on = ActiveModel::Type::Boolean.new.cast(value) ? 'both' : 'back_end'
  end
end

Drops cleanly in 6.0 when the columns rename: delete the alias_attribute lines, replace display_on/storefront_visible with the boolean column.

load_parent_from_route walks a small map of param_name → model_class, picks the first one present, calls find_by_prefix_id!. Same pattern any future polymorphic admin controller will use.

Validation rules

  • A custom_field_definition_id is required — Spree::Metafield validates metafield_definition presence. There is no free-form mode (clients can't supply key + type without a definition).
  • The custom field's STI type is auto-set from the linked definition's metafield_type in a before_validation callback. Clients never supply it.
  • (metafield_definition_id, resource_type, resource_id) is unique — DB-enforced. Updating an existing custom field's value is the upsert path; there is no "patch the definition link" operation (delete and re-create instead).
  • value is validated by the type's STI subclass (ShortText, Number, etc.).

No new validation logic at the controller layer — the model and the database carry it.

SDK — first-class six + generic escape hatch

Hand-write SDK methods on the six most-used parents in the SPA. Plugins extend the SDK via the same TS pattern — they import createCustomFieldsApi and attach it to their own resource client. Everything else uses a generic.

First-class parents (6):

ts
adminClient.products.customFields    // .list, .get, .create, .update, .delete
adminClient.variants.customFields    // (when variants get a top-level admin route)
adminClient.orders.customFields
adminClient.customers.customFields
adminClient.categories.customFields
adminClient.optionTypes.customFields

Same pattern for definitions:

ts
adminClient.products.customFieldDefinitions
adminClient.orders.customFieldDefinitions

Generic escape hatch:

ts
adminClient.customFields(ownerType: 'Spree::Product', ownerId: 'prod_xxx').list()

Internally, the generic is a tiny URL builder that maps ownerType → admin URL segment (Spree::Productproducts). Plugins can also call this directly without registering a first-class binding. Used sparingly — most SPA pages use the first-class methods because they read better.

Why six and not all 29: the SPA's detail-page surface is products, orders, customers (live today) plus variants, categories, option_types (Phase 4 of 6.0-admin-spa.md). The remaining 23 are configuration-screen owners (StockItem, ShippingMethod, etc.) — when they need custom-field UI, the generic works fine, and we can promote them to first-class when the second consumer asks.

SDK file structure:

packages/admin-sdk/src/
├── resources/
│   ├── custom-fields.ts            # createCustomFieldsApi(client, parentPath)
│   ├── custom-field-definitions.ts # createCustomFieldDefinitionsApi(...)
│   ├── products.ts                 # adds .customFields = createCustomFieldsApi(client, `/products/${id}`)
│   ├── orders.ts
│   └── …
├── admin-client.ts                 # exposes generic .customFields(ownerType, ownerId)

Each first-class binding is one line in its parent's resource file: customFields: createCustomFieldsApi(client, ...). Adding a seventh first-class parent later is a one-line change.

Deferred: definitions admin

Definitions are managed in a separate UI surface (Settings → Custom Field Definitions, similar to the Rails admin's existing metafield_definitions page). The CRUD endpoints above support it; the SPA page is Phase 4 of 6.0-admin-spa.md. The drawer reads definitions to type-aware-render the editor but doesn't manage them.

Order of work

  1. Backend route concern + controllers (custom_fields + custom_field_definitions)
  2. Integration specs covering the six first-class parents (one parent's spec serves as the template; the rest are near-copies)
  3. SDK first-class bindings on the six parents + generic escape hatch
  4. SDK tests (MSW-based, mirror integration specs)
  5. SPA: <CustomFieldsDrawer> consumes the six bindings, gated by <PageHeader jsonPreview>-style prop
  6. SPA: extend drawer to use the customFieldDefinitions endpoint for type-aware editor

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