docs/developer/tutorial/extending-models.mdx
In this tutorial, we'll connect our custom Brand model with Spree's core Product model. This is a common pattern when building features that need to integrate with existing Spree functionality.
<Info> This guide assumes you've completed the [Model](/developer/tutorial/model), [Admin](/developer/tutorial/admin), and [File Uploads](/developer/tutorial/file-uploads) tutorials. </Info>By the end of this tutorial, you'll have:
Before extending Spree models, consider which approach fits your needs best:
| What you need | Recommended approach |
|---|---|
| Add association (belongs_to, has_many) | Decorator (this tutorial) |
| Add validation or scope | Decorator |
| Add new instance/class method | Decorator |
| React to model changes (after save, etc.) | Events subscriber |
| Sync with external service on changes | Events or Webhooks |
| Add searchable/filterable field | Ransack configuration |
| Add admin UI elements | Admin Partials |
When working with Spree, you'll often need to add functionality to existing models like Spree::Product or Spree::Order. However, you shouldn't modify these files directly because:
Instead, we use decorators - a Ruby pattern that lets you add or modify behavior of existing classes without changing their original source code.
In Ruby, classes are "open" - you can add methods to them at any time. Decorators leverage this by:
Module#prepend to inject your module into the class's inheritance chainsuper to invoke the original method# This is the basic pattern
module Spree
module ProductDecorator
# Add a new method
def my_new_method
"Hello from decorator!"
end
# Override an existing method
def existing_method
# Do something before
result = super # Call the original method
# Do something after
result
end
end
Product.prepend(ProductDecorator)
end
The key line is Product.prepend(ProductDecorator) - this inserts your module at the beginning of the method lookup chain, so your methods are found first.
First, add a brand_id column to the products table:
bin/rails g migration AddBrandIdToSpreeProducts brand_id:integer:index
Edit the migration to add an index (but no foreign key constraint, keeping it optional):
class AddBrandIdToSpreeProducts < ActiveRecord::Migration[8.0]
def change
add_column :spree_products, :brand_id, :integer
add_index :spree_products, :brand_id
end
end
Run the migration:
bin/rails db:migrate
Spree provides a generator to create decorator files with the correct structure:
bin/rails g spree:model_decorator Spree::Product
This creates app/models/spree/product_decorator.rb:
module Spree
module ProductDecorator
def self.prepended(base)
# Class-level configurations go here
end
end
Product.prepend(ProductDecorator)
end
Update the decorator to add the belongs_to association:
module Spree
module ProductDecorator
def self.prepended(base)
base.belongs_to :brand, class_name: 'Spree::Brand', optional: true
end
end
Product.prepend(ProductDecorator)
end
self.prepended(base) - This callback runs when the module is prepended to a class. The base parameter is the class being decorated (Spree::Product)base.belongs_to - We call class methods on base to add associations, validations, scopes, etc.optional: true - Products don't require a brand (the brand_id can be NULL)Now update your Brand model to define the reverse association:
module Spree
class Brand < Spree::Base
# ... existing code ...
has_many :products, class_name: 'Spree::Product', dependent: :nullify
# ... rest of your model ...
end
end
For the admin form to save the brand association, we need to permit the brand_id parameter.
Add to your Spree initializer:
# .. other code ..
Spree::PermittedAttributes.product_attributes << :brand_id
Create a partial to inject the brand selector into the product form. Spree's admin product form has injection points for customization.
Create the partial:
<div class="card mb-6">
<div class="card-header">
<h5 class="card-title"><%= Spree.t(:brand) %></h5>
</div>
<div class="card-body">
<%= f.spree_select :brand_id,
Spree::Brand.order(:name).pluck(:name, :id),
{ include_blank: true, label: Spree.t(:brand) } %>
</div>
Register this partial to appear in the product form. Add to your initializer:
<Tabs> <Tab title="Spree 5.2+"> ```ruby config/initializers/spree.rb Rails.application.config.after_initialize do Spree.admin.partials.product_form_sidebar << 'spree/admin/products/brand_field' end ``` </Tab> <Tab title="Spree 5.1 and below"> ```ruby config/initializers/spree.rb Rails.application.config.spree_admin.product_form_sidebar_partials << 'spree/admin/products/brand_field' ``` </Tab> </Tabs>Add the translation for the brand label:
en:
spree:
brand: Brand
Verify everything works in the Rails console:
# Create a brand and product
brand = Spree::Brand.create!(name: 'Nike')
product = Spree::Product.first
product.update!(brand: brand)
# Test associations
product.brand # => #<Spree::Brand id: 1, name: "Nike"...>
brand.products # => [#<Spree::Product...>]
brand.products.count # => 1
def self.prepended(base)
base.validates :custom_field, presence: true
end
def self.prepended(base)
base.scope :featured, -> { where(featured: true) }
end
Decorator approach (use only for simple, internal logic):
def self.prepended(base)
base.before_save :normalize_name
end
def normalize_name
self.name = name.strip.titleize if name.present?
end
Events approach (recommended for side effects):
module MyApp
class ProductSyncSubscriber < Spree::Subscriber
subscribes_to 'product.updated'
def handle(event)
product = Spree::Product.find_by(id: event.payload['id'])
return unless product
ExternalSyncService.sync(product)
end
end
end
def self.prepended(base)
base.extend ClassMethods
end
module ClassMethods
def my_class_method
# Class method logic
end
end
module Spree
module ProductDecorator
def self.prepended(base)
base.belongs_to :brand, class_name: 'Spree::Brand', optional: true
end
end
Product.prepend(ProductDecorator)
end
module Spree
class Brand < Spree::Base
has_many :products, class_name: 'Spree::Product', dependent: :nullify
has_one_attached :logo
has_rich_text :description
validates :name, presence: true
end
end
# Permit brand_id in product params
Spree::PermittedAttributes.product_attributes << :brand_id
Now that Brands are connected to Products, let's expose them through the Store API:
<Card title="6. Store API" icon="code" href="/developer/tutorial/store-api"> Create API endpoints for brands and extend the Product API response </Card>