Loading...
Loading...
Use FIRST when a Spree 6 customization is on the table and the right pattern isn't obvious — "how do I customize X", "where should this logic go", "decorator or subscriber?", "hook or event?", "how do I block checkout when…", "add a field to products", "change how tax/shipping/search works", "extend Spree without forking". A decision tree that routes each need to the right extension point (store settings, SPREE_* config, model preferences, workflow hooks, events, webhooks, Checkout::Registry, providers, custom fields vs metadata, validators, permitted attributes, permission scopes, new resources, dependencies, decorators, dashboard plugins) and to the skill that covers it in depth.
npx skill4agent add spree/agent-skills spree-customizationCommands use theCLI form. On a classic Rails app usespreefrom the app root and drop thebin/rails …path prefix — seeserver/.spree-project
| You want to… | Reach for | Skill |
|---|---|---|
| Change currency, markets, languages, delivery zones/methods, tax rates, payment methods, order numbering | Store settings — dashboard → Settings, or the Admin API. Data, no deploy | |
| Tune installation-wide limits (password length, JWT expiry, rate limits, cart expiry, webhooks on/off) | | — (docs: |
Give a record type its own typed settings (a | Model preference — | |
| Veto an operation (purchase limits, region policy, B2B eligibility) | Workflow | |
| Run code inside a core flow (after finalize, before cancel, feed pricing/provider context) | Workflow lifecycle / context hook — | |
| React after something happened in your own app (index, sync, email, audit) | Event subscriber — | |
| Tell an external system something happened, no Ruby | Webhook endpoint — dashboard → Settings → Webhooks, or Admin API | |
| Require something before checkout completes (PO number, phone, age check) or add a checkout step | | |
| Replace how tax, delivery rates, fulfillment, search, pricing, inventory, payouts, digital delivery or payments work | Provider — register a class ( | |
| Sign customers/staff in with your own IdP or token | Auth strategy — | |
| Store extra merchant-facing data (material, care instructions, gift message) | Custom fields — | |
| Store integration/machine data (ERP id, UTM source, sync state) | | |
Make an attribute filterable/sortable via | | |
| Add or drop an address rule (PO boxes, phone format) | | |
| Make a new column writable through the Admin API | | |
| Gate a new resource behind staff roles / API key scopes | | |
| Add a whole new model + Admin/Store endpoint | | |
| Add a status to a core model | | |
| Change what a core flow fundamentally does | Dependency swap — | |
| Add an association, scope or helper method to a core model | Decorator ( | |
| Add admin screens, fields, columns, nav items | Dashboard plugin in | |
| Share any of the above across apps | Extension gem (Rails engine) + optional dashboard plugin ( | |
validatebefore_cancel# server/config/initializers/spree.rb
Spree.hooks.register('carts.complete.validate', 'MyApp::RegionPolicy')
# server/app/services/my_app/region_policy.rb
module MyApp
class RegionPolicy
def call(workflow)
return unless workflow.cart.ship_address&.country_code == 'KP'
workflow.reject!('We cannot deliver to this region')
end
end
endSpree::Hooks::UnknownHookErrorreject!spree generate subscriber ErpOrderSync order.placed # creates the class AND registers it# server/app/subscribers/erp_order_sync_subscriber.rb
class ErpOrderSyncSubscriber < Spree::Subscriber
subscribes_to 'order.placed'
def handle(event)
order = Spree::Order.find_by_prefix_id(event.payload['id'])
MyApp::Erp.push(order) if order
end
endSpree.subscribers << ErpOrderSyncSubscriberSpree::Checkout::Registry.add_requirement(
step: :payment,
field: :po_number,
message: 'PO number is required',
satisfied: ->(cart) { cart.metadata['po_number'].present? },
applicable: ->(cart) { cart.company.present? }
)requirementsCarts::Completebrand_idspree generate api_resource Brand name:string
spree generate migration AddBrandIdToSpreeProducts brand_id:bigint:index
spree migrate# server/config/initializers/spree.rb
Rails.application.config.to_prepare do
Spree::Product.additional_permitted_attributes += [:brand_id] # += — the default is frozen
Spree::Product.storefront_ransackable_associations |= %w[brand] # Store API filters follow only this list
end
Spree.ransack.add_association(Spree::Product, :brand) # Admin API filters
Spree.permissions.register_scope(:brands, group: :catalog, resources: -> { [Spree::Brand] })
# server/app/models/spree/product_decorator.rb — structural addition, the one legit decorator job
module Spree
module ProductDecorator
def self.prepended(base)
base.belongs_to :brand, class_name: 'Spree::Brand', optional: true
end
end
Product.prepend(ProductDecorator)
end# server/config/initializers/spree.rb
Rails.application.config.to_prepare do
Spree.validators.addresses.register(MyApp::PostBoxValidator) # an ActiveModel::Validator
Spree.validators.addresses.unregister(Spree::Addresses::PhoneValidator)
endaddress_requires_phonecompany_field_enableddisable_sku_validationvalidateSpree::Dependencies.cart_add_item_workflow = 'MyApp::Carts::AddItem' # subclass Spree::Carts::AddItem_workflowcarts_complete_workfloworder_cancel_workflowcart_recalculate_totals_workflowfulfillment_update_workflow_serviceorder_update_statuses_servicefinalize!after_finalizecarts.complete.after_finalizeorders.cancel.after_cancelstate_machineafter_transitioncheckout_flowafter_saveafter_commitclear_validators!Spree.validators.addresses.unregisterSpree::Product.additional_permitted_attributes << :brand_idFrozenError+=*_servicecart_add_item_servicecarts_complete_service*_workflowpayment_statusfulfillment_statusSpree::Orders::UpdateStatusesinclude Spree::HasStatus; has_status :a, :b, default: :aserver/app/node_modules/@spree/docs/dist/developer/customization/quickstart.md…/customization/{configuration,workflows,dependencies,decorators,validations,metadata,model-preferences,permissions,checkout,api}.md…/providers/overview.md…/how-to/custom-*.md…/dashboard/customization/quickstart.md