docs/plans/6.0-replace-taxons-with-categories.md
Status: Design finalized, implementation not started Target: Spree 6.0 Depends on: API v3 conventions, ProductType plan (6.0-product-types.md) Author: Damian + Claude Last updated: 2026-03-16
Split the overloaded Taxon model into two distinct concepts that every modern commerce platform separates:
Drop the Taxonomy model entirely — it's a grouping layer that adds indirection without value. Categories become a single tree per store (rooted, nested set). Collections are flat, store-scoped, and own the current automatic/rule-based functionality.
This also renames Classification to ProductCategory and TaxonRule to CollectionRule.
"Taxon" and "Taxonomy" are jargon. No other commerce platform uses these terms. Every new developer has to learn what they mean. The industry-standard separation is Category (hierarchical) + Collection (flat/curated).
Taxon is overloaded. The same model serves as both hierarchical categories AND flat collections (via the "Collections" taxonomy with automatic rules). These have fundamentally different semantics:
Taxonomy adds indirection without value. Taxonomy is essentially a named container for a tree of Taxons, scoped to a Store. The root Taxon mirrors the Taxonomy name (kept in sync via callbacks). In practice, merchants have 2-3 taxonomies ("Categories", "Brands", "Collections"). This grouping can be replaced by a kind column on the category tree root or by separating Collections entirely.
Classification tells you nothing. The Product↔Taxon join model name gives no hint about what it joins.
| Current | API name | Prefix | Notes |
|---|---|---|---|
Spree::Taxonomy | n/a (internal) | txnmy_ | Container for taxon trees, scoped to store |
Spree::Taxon | categories | ctg_ | Nested set tree, has automatic flag + TaxonRule |
Spree::Classification | n/a | n/a | Product↔Taxon join (table: spree_products_taxons) |
Spree::TaxonRule | n/a | txrule_ | STI: AvailableOn, Sale, Tag |
Spree::Category | categories | ctg_ | Already exists as < Taxon alias |
The API v3 already exposes taxons as categories via the CategorySerializer. The Spree::Category class already exists as a subclass alias. The prefix ctg_ is already in use.
Default taxonomies created per store: "Categories", "Brands", "Collections". Automatic taxons ("On Sale", "New Arrivals") live under the "Collections" taxonomy.
Spree::Taxon renamed to Spree::Category, spree_taxons renamed to spree_categoriesSpree::Taxonomy dropped. Categories are a single nested set per store. The taxonomy_id column becomes store_id directly on the category.Spree::Classification renamed to Spree::ProductCategory, table spree_products_taxons renamed to spree_product_categories, columns taxon_id → category_idautomatic flag, rules_match_policy, sort_order, and taxon_rules association move to Collection.ctg_ (already in use)awesome_nested_set with parent_id, lft, rgt, depth, children_count. Proven, no reason to change.pretty_name and hierarchical permalink stay. These are valuable for breadcrumbs and SEO.hide_from_nav stays on Category — useful for categories that exist for organization but shouldn't appear in navigation.Spree::Collection — flat (no tree), store-scopedSpree::ProductCollection (replaces the automatic taxon's use of Classification)automatic flag, rules_match_policy, and rules association move here from TaxonSpree::TaxonRule renamed to Spree::CollectionRule — STI subtypes: AvailableOn, Sale, Tag (plus future rules)coll_ProductCollection records managed by admins. Automatic collections have CollectionRule records that regenerate membership.SORT_ORDERS constant (manual, best_selling, price asc, etc.) moves to Collection.kind string column on Category if explicit grouping is needed.store_id (required). Children auto-copy from parent on create. Simple where(store_id:) scoping, no joins needed.class Spree::Category < Spree.base_class
has_prefix_id :ctg
include Spree::TranslatableResource
include Spree::TranslatableResourceSlug
include Spree::Metafields
include Spree::Metadata
include Spree::MemoizedData
acts_as_nested_set dependent: :destroy, counter_cache: :children_count
# Every category has store_id — children copy from parent on create
belongs_to :store, class_name: 'Spree::Store'
validates :store, presence: true
before_validation :copy_store_from_parent, if: -> { store_id.blank? && parent.present? }
# Products
has_many :product_categories, class_name: 'Spree::ProductCategory', dependent: :destroy_async
has_many :products, through: :product_categories
# ProductType integration (from 6.0-product-types.md)
has_many :product_type_categories, class_name: 'Spree::ProductTypeCategory', dependent: :destroy
has_many :product_types, through: :product_type_categories
# Promotions
has_many :promotion_rule_categories, class_name: 'Spree::PromotionRuleCategory', dependent: :destroy
has_many :promotion_rules, through: :promotion_rule_categories
# Attachments
has_one_attached :image
has_one_attached :square_image
# Validations
validates :name, presence: true, uniqueness: { scope: %i[parent_id store_id], case_sensitive: false }
validates :hide_from_nav, inclusion: { in: [true, false] }
# Translations
TRANSLATABLE_FIELDS = %i[name pretty_name description permalink].freeze
translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
translates :description, backend: :action_text
# Scopes
scope :for_store, ->(store) { where(store_id: store.id) }
# No automatic/rules — that's Collection's job
end
class Spree::Collection < Spree.base_class
has_prefix_id :coll
include Spree::SingleStoreResource
include Spree::TranslatableResource
include Spree::TranslatableResourceSlug
include Spree::Metafields
include Spree::Metadata
RULES_MATCH_POLICIES = %w[all any].freeze
SORT_ORDERS = %w[
manual best_selling
price_asc price_desc
available_on_desc available_on_asc
name_asc name_desc
].freeze
acts_as_list scope: :store_id
belongs_to :store, class_name: 'Spree::Store'
# Products
has_many :product_collections, class_name: 'Spree::ProductCollection', dependent: :destroy_async
has_many :products, through: :product_collections
# Automatic rules
has_many :collection_rules, class_name: 'Spree::CollectionRule', dependent: :destroy
accepts_nested_attributes_for :collection_rules, allow_destroy: true,
reject_if: proc { |attrs| attrs['value'].blank? }
# Attachments
has_one_attached :image
has_one_attached :square_image
# Validations
validates :name, presence: true
validates :store, presence: true
validates :rules_match_policy, inclusion: { in: RULES_MATCH_POLICIES }, presence: true
validates :sort_order, inclusion: { in: SORT_ORDERS }, presence: true
# Translations
TRANSLATABLE_FIELDS = %i[name description permalink].freeze
translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
scope :manual, -> { where.not(automatic: true) }
scope :automatic, -> { where(automatic: true) }
# Regenerate product membership from rules (moved from Taxon)
def regenerate_products
return unless automatic?
Spree::Collections::RegenerateProducts.call(collection: self)
end
def products_matching_rules(opts = {})
return Spree::Product.none unless automatic? && collection_rules.any?
# Same logic as current Taxon#products_matching_rules
end
end
# Renamed from Spree::Classification
class Spree::ProductCategory < Spree.base_class
self.table_name = 'spree_product_categories'
acts_as_list scope: :category
belongs_to :product, class_name: 'Spree::Product', counter_cache: :category_count, touch: true
belongs_to :category, class_name: 'Spree::Category', counter_cache: :product_count, touch: true
validates :category, :product, presence: true
validates :category_id, uniqueness: { scope: :product_id }
end
# New
class Spree::ProductCollection < Spree.base_class
self.table_name = 'spree_product_collections'
acts_as_list scope: :collection
belongs_to :product, class_name: 'Spree::Product', touch: true
belongs_to :collection, class_name: 'Spree::Collection', touch: true
validates :collection, :product, presence: true
validates :collection_id, uniqueness: { scope: :product_id }
end
class Spree::CollectionRule < Spree.base_class
has_prefix_id :crule
MATCH_POLICIES = %w[is_equal_to is_not_equal_to contains does_not_contain].freeze
belongs_to :collection, class_name: 'Spree::Collection', inverse_of: :collection_rules, touch: true
validates :collection, :type, :value, presence: true
validates :match_policy, inclusion: { in: MATCH_POLICIES }, presence: true
after_commit :regenerate_collection_products,
if: -> { saved_change_to_value? || destroyed? || saved_change_to_match_policy? }
end
# STI subtypes:
# Spree::CollectionRules::AvailableOn — products created/available within N days
# Spree::CollectionRules::Sale — products currently on sale
# Spree::CollectionRules::Tag — products with matching tags
class Spree::Product < Spree.base_class
# Categories (renamed from taxons/classifications)
has_many :product_categories, dependent: :delete_all, inverse_of: :product
has_many :categories, through: :product_categories
# Backward compat alias (deprecation period)
alias_method :taxons, :categories
alias_method :classifications, :product_categories
# Collections (new)
has_many :product_collections, dependent: :delete_all, inverse_of: :product
has_many :collections, through: :product_collections
# main_taxon → primary_category
def primary_category
@primary_category ||= categories.first
end
# brand_taxon → brand (lookup by taxonomy name replaced by category kind or brand association)
# See Open Questions for brand handling
end
class Spree::Store < Spree.base_class
# Drop: has_many :taxonomies
# Drop: has_many :taxons, through: :taxonomies
has_many :categories, class_name: 'Spree::Category'
has_many :root_categories, -> { roots }, class_name: 'Spree::Category'
has_many :collections, class_name: 'Spree::Collection'
end
Store API already uses categories naming. Changes:
# Categories (existing endpoints, just renamed internally)
GET /api/v3/store/categories # was taxons
GET /api/v3/store/categories/:id
GET /api/v3/store/products/:id?expand=categories
# Collections (new)
GET /api/v3/store/collections
GET /api/v3/store/collections/:id
GET /api/v3/store/collections/:id/products
# Admin API
GET /api/v3/admin/categories
POST /api/v3/admin/categories
PATCH /api/v3/admin/categories/:id
DELETE /api/v3/admin/categories/:id
GET /api/v3/admin/collections
POST /api/v3/admin/collections
PATCH /api/v3/admin/collections/:id
DELETE /api/v3/admin/collections/:id
Collection response:
{
"id": "coll_k5nR8xLq",
"name": "Summer Sale",
"automatic": true,
"sort_order": "best_selling",
"rules_match_policy": "all",
"rules": [
{ "id": "crule_abc", "type": "Spree::CollectionRules::Tag", "value": "summer", "match_policy": "contains" }
],
"image_url": "https://...",
"products_count": 42,
"slug": "summer-sale"
}
Single migration:
class RenameTaxonsToCategories < ActiveRecord::Migration[7.2]
def change
# Rename core tables
rename_table :spree_taxons, :spree_categories
rename_table :spree_products_taxons, :spree_product_categories
rename_table :spree_taxon_rules, :spree_collection_rules
# Rename FK columns
rename_column :spree_product_categories, :taxon_id, :category_id
rename_column :spree_collection_rules, :taxon_id, :collection_id
# Add store_id to categories (replacing taxonomy_id)
add_column :spree_categories, :store_id, :bigint
add_index :spree_categories, :store_id
# Rename counter caches
rename_column :spree_categories, :classification_count, :product_count
rename_column :spree_products, :classification_count, :category_count
# Create collections table
create_table :spree_collections do |t|
t.string :name, null: false
t.text :description
t.string :permalink
t.boolean :automatic, null: false, default: false
t.string :rules_match_policy, null: false, default: 'all'
t.string :sort_order, null: false, default: 'manual'
t.boolean :hide_from_nav, null: false, default: false
t.integer :position
t.integer :products_count, null: false, default: 0
t.references :store, null: false
t.string :meta_title
t.string :meta_description
t.string :meta_keywords
t.jsonb :public_metadata
t.jsonb :private_metadata
t.timestamps
end
add_index :spree_collections, [:store_id, :permalink], unique: true
# Create product_collections join table
create_table :spree_product_collections do |t|
t.references :product, null: false
t.references :collection, null: false
t.integer :position
t.timestamps
end
add_index :spree_product_collections, [:collection_id, :product_id], unique: true
end
end
# rake spree:migrate_taxons_to_categories_and_collections
#
# For each Taxonomy:
# 1. Set store_id on all its root + descendant taxons (now categories)
# 2. If taxonomy is "Collections" (by name):
# - Move its automatic child taxons to spree_collections
# - Move their TaxonRules to CollectionRules
# - Move their Classifications to ProductCollections
# - Move manual child taxons to ProductCollections
# - Delete the taxonomy root category
# 3. For other taxonomies ("Categories", "Brands"):
# - Root category keeps the taxonomy name
# - Remove taxonomy_id column references
# 4. Drop taxonomy_id from categories
Spree::Taxon → Spree::Category (alias Spree::Taxon = Spree::Category for one release)Spree::Taxonomy → deprecated, no replacementSpree::Classification → Spree::ProductCategory (alias for one release)Spree::TaxonRule → Spree::CollectionRuleSpree::TaxonRules::* → Spree::CollectionRules::*taxonomy_id column from categoriesspree_taxonomies tableautomatic, rules_match_policy, sort_order columns from categories (moved to collections)categories in all new API code. The Store API already does this. Don't introduce new taxon references in API v3.Spree::Category alias in new code where possible. It exists today as < Taxon.ctg_ prefix. Already in use, will carry over.ProductTypeCategory join table (from 6.0-product-types.md) should be named anticipating the Category rename — use category_id column, not taxon_id.Brand handling. Currently brand_taxon looks up a taxon under the "Brands" taxonomy. Options:
Spree::Brand model — cleaner but more migration workproduct.brand resolves to the first category under the Brands root.Category kind/type column. Should root categories have a kind column (e.g., categories, brands) to replace the Taxonomy name as a semantic grouping? This would make "find the brands root" a query on kind rather than a string match on name. Low cost, high value.
Collection pagination of products. Should GET /collections/:id/products support full Ransack filtering and Pagy pagination, or should it return the collection's pre-sorted product list? Probably both — Ransack filters scoped to the collection's product set.
Automatic collection scheduling. Current AvailableOn rule regenerates on product changes. Should collections support scheduled regeneration (e.g., daily cron) for time-based rules? The current after_commit approach may miss edge cases.
Collection nesting. The plan says collections are flat. Should we support one level of nesting (parent collection)? Some platforms support hierarchical collections with filter inheritance. Could be a future enhancement.
categories naming in Store API v36.0-product-types.md (ProductTypeCategory join table)