Before proceeding to upgrade, please ensure you’re at Spree 5.6. Spree 6.0 requires Rails 8.1 and is the designated breaking-change window for the platform — read the behavioral changes section even if your upgrade runs clean.
- Cart and Order are separate models.
Spree::Cartowns shopping and checkout; completing checkout copies the cart into an immutableSpree::Order. The order state machine is gone. - Checkout has no server-side state machine. Steps are advisory metadata for your frontend; the backend enforces exactly one hard gate — completion.
- Adjustments are typed rows. The polymorphic
Spree::Adjustmentis replaced bySpree::TaxLine,Spree::DiscountandSpree::Fee. - Master Variant is gone, replaced by a
default_variant_idforeign key onSpree::Product. There’s no hidden/dummy Variant created now, the default variant is a real Variant with its own SKU, price, stock, etc. - Fulfillment vocabulary.
Shipment→Fulfillment,ShippingMethod→DeliveryMethod,Zone→DeliveryZone, with a pluggableFulfillmentProviderstrategy. - Two-tier services. Plain services in
app/services, plusSpree::Workflowclasses inapp/workflowsfor the curated multi-step flows (completion, cancellation, recalculation) — with named steps, instrumentation and extension hooks.
- Update the Ruby gems
- Run database migrations
- Run data backfills — convert your existing records into the new schema
- Review behavioral changes — this release changes runtime behavior, not just schema
How to upgrade
bundle update, update your Gemfile: the Rails admin (spree_admin) and Rails storefront (spree_storefront) are not part of Spree 6.0. Remove both and add spree_dashboard, which serves the new React admin dashboard at /dashboard.
bundle exec rake spree:upgrade figures out what still needs to happen and does nothing on data that’s already migrated. It also runs every data backfill from earlier upgrades (5.4 → 5.5 and 5.5 → 5.6), so any you missed along the way are caught up. That does not remove the requirement above: upgrade your application to 5.6 first.
What the upgrade does
Reference material — the data backfillsbundle exec rake spree:upgrade executes. Every task is idempotent.
Convert incomplete orders into carts
Spree::Cart with the same token, so in-flight guest and customer checkouts survive the deploy. The cart re-owns the order’s line items, fulfillments, payments, payment sessions, reservations and coupon codes; the hollow order row is deleted. Completed and canceled orders are untouched. Orders holding payment sessions convert last, so an interrupted run leaves the riskiest rows for the retry.
Convert legacy adjustments into typed rows
spree_adjustments into Spree::TaxLine, Spree::Discount and Spree::Fee rows. Orders whose typed sums do not reconcile with the stored totals are left untouched and flagged (metadata['typed_adjustments_frozen']) for manual review instead of silently changing money.
Backfill fulfillment and delivery naming
Spree::Zone records into Spree::DeliveryZone with typed members, and converts FlatRate calculator eligibility bounds into Spree::DeliveryMethodRule records.
Remove master variants
default_variant_id foreign key; is_master is gone from the models. (The physical column drop lands in 6.1.)
Categories and collections
Spree::Category (hierarchy) and automatic taxons become Spree::Collection (flat, rule-based). Spree::Taxon remains as an alias for one release.
Move rich text out of Action Text
action_text_rich_texts into text columns on their own tables. Per-locale rows land in the model’s translation table.
Run this after two earlier steps, both load-bearing: the categories step re-points the rows from Spree::Taxon to Spree::Category so this one can still find them, and the customers step populates spree_customers — a note is copied onto the customer row it belongs to, so running before those rows exist would treat every legacy customer note as orphaned and skip it for good. Following the manifest order handles this for you.
Content is sanitized on the way in, and the 6.0 allowlist is much narrower than 5.6’s: it permits only what the dashboard’s editor emits — paragraphs, headings, strong/em/s/u/code, pre, blockquote, lists, hr, br, links and images. Tables, div/span, inline style and arbitrary class attributes are no longer permitted. Text inside a stripped tag survives; its formatting does not. The exceptions are script and style, which are removed along with their contents — a script body would otherwise reappear as visible text.
If your descriptions rely on richer markup, permit it in an initializer before running the task and before saving anything under 6.0:
spree_core dropped require 'action_text/engine' — along with action_cable/engine, which nothing in Spree used — and a fresh install no longer creates the tables. The rake task requires Action Text itself, so the upgrade works either way. If your own code uses has_rich_text, Action Text view helpers, or Action Cable, require what you need in config/application.rb (apps generated from the Spree starter already do):
Backfill order coupon codes
spree_orders gains a coupon_code column (parity with carts). Historical placed orders applied coupons only through the promotion join tables — this fills the column from the attached coupon-code record (or the applied single-code promotion) so admin filtering and the serializer answer consistently for old orders.
Markets on orders
market is required on carts and orders in 6.0.
Fill in the stock level counters
spree_stock_levels gains reserved_count (units held by checkouts in progress) and incoming_count (units on their way on a purchase order placed with a supplier or a transfer in transit). Both are kept by the workflows that change them and start at zero on an existing install; this recomputes them from active reservations and open documents and prints every level it corrected. Until it runs, the dashboard’s Inventory page shows zero reserved and incoming for stock that predates the upgrade. Safe to re-run at any time.
Name who performed past actions
canceler_id, approver_id,
created_by_id, refunder_id, received_by_id — gained a type column beside
it (canceler_type, and so on) naming which kind of actor the id points at.
Rows written before the upgrade carry an id and an empty type; this task fills
the type in. Until it runs they still read as the staff member they always
were, with a deprecation warning. Safe to re-run at any time.
Move users onto the Customer and AdminUser models
spree_users) into spree_customers, keeping the same ids, and carries existing passwords over so customers and staff can keep signing in. The source table is left in place as a safety net.
Name countries and states by ISO code
country_code, state_code), and this task fills those columns from the old country_id / state_id references. The old country_iso / state_abbr names still work on addresses for one release.
Enable Active Record encryption
Spree encrypts webhook endpoint signing secrets, payment gateway customer IDs and the OAuth tokens on user identities with Active Record encryption — but only when encryption keys are configured. Without keys these values are stored in plain text. New 6.0 projects get keys at setup; upgraded applications need to add them. 1. Make the app read the keys. Projects created fromspree-starter read them from the ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY, ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY and ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT env vars, falling back to the active_record_encryption entry in Rails credentials. If your config/application.rb predates this, add inside the Application class:
config/application.rb
config.active_record.encryption (rather than only in credentials) matters: Spree checks that configuration when it decides whether to encrypt webhook secrets and gateway customer IDs.
2. Generate keys for development and production — a separate set for each environment:
.env (spree update, or spree dev for an ejected project — spree restart keeps the old environment). Set the keys on your production host before deploying, and store them in your secret manager.
3. Encrypt existing rows. Rows written before the keys were set are plain text. OAuth tokens on user identities stay readable and are encrypted on their next write. Webhook endpoint secrets and gateway customer IDs can’t be read in plain text once encryption is on — follow Enabling encryption on an existing installation: turn on support_unencrypted_data and extend_queries, deploy with the keys, run find_each(&:encrypt) over Spree::WebhookEndpoint, Spree::GatewayCustomer and Spree::UserIdentity, then turn the two settings off again.
The Cart/Order split
The single biggest change. What used to be oneSpree::Order living through checkout and beyond is now two models:
Spree::Cart(spree_carts, prefixed IDscart_...) owns the shopping and checkout phase: line items, addresses, payments-in-progress, delivery proposals, promotions, stock reservations. Carts have no status column —completed_atis the only lifecycle marker.Spree::Orderis created by completion — the cart is copied into it (line items, fulfillments, addresses, typed money rows — copies, never shared rows). Orders carrystatus(draft/placed/canceled) and are money-frozen once placed.
- Completed carts are read-only. The cart is retained after completion (abandonment analytics, idempotent replay) but rejects every write. Post-checkout life belongs to the order.
- Completion is idempotent.
Spree::Carts::Completeguards with a uniquespree_orders.cart_idindex and acompleting_atlock: a double-clicked Place Order returns the same order, a crashed completion replays safely, and a pre-capture payment failure rolls the draft order back and re-points payments to the cart. - The guest token carries over from cart to order, so confirmation pages keep working with the credential the guest already holds.
- Dual concrete FKs, not polymorphism. Records owned by either side (
LineItem,Fulfillment,TaxLine,Discount,Fee,Payment,StockReservation) carry nullablecart_id+order_idwith an exactly-one rule and an#ownermethod. Code that assumedline_item.orderis always present must readline_item.owner. - Shared model surface lives in
Spree::Purchase::*concerns (addresses, taxation, store credits, gift cards, digital items, payment processing, market/channel/currency/locale resolution) — included by both Cart and Order. Decorators targetingSpree::Ordermethods that moved should decorate the concern or the new owner.
Checkout without a state machine
Spree::Order no longer has a state column, a state machine, or the checkout_flow DSL. If your app customized checkout with checkout_flow, go_to_state, insert_checkout_step, remove_checkout_step or remove_transition — those APIs are gone (not deprecated: the machine they configured no longer exists).
The replacement model:
- Steps are advisory.
cart.checkout_steps,current_checkout_stepandcompleted_checkout_stepsare derived from cart data — there is no stored step and no server-side sequencing. Clients may write any checkout field in any order. “Steps” are a grouping label telling your frontend which page an unmet requirement belongs to. - One hard gate.
Spree::Carts::Completeis the only place checkout is enforced. It validates the full requirement battery (line items, email, addresses, delivery selection, payment coverage, per-item stock, discontinued products, guest policy) and returns structured{ step, field, code, message }errors. Spree::Checkout::Registryis the extension surface. One declaration serves both the advisory feed and the completion gate:
to_prepare: the registry is never reset, so a to_prepare block would add the requirement again on every code reload in development.
The requirement appears in the Cart API’s requirements array (so a storefront rendering the feed generically needs zero changes) and blocks completion. register_step adds whole steps (spliced into checkout_steps at before:/after: anchors); built-in steps are customized through Registry.base_steps — an ordered { name => applicability } hash you can mutate directly (base_steps.delete('confirm')).
- The API
requirementsarray now carries a stablecodeon every entry (email_required,out_of_stock,guest_checkout_not_allowed, …). Additive change — existing consumers keep working. - The delivery requirement is keyed
delivery_method, notshipping_method. Its entry is now{ step: 'delivery', field: 'delivery_method', code: 'delivery_method_required' }. A storefront that renders the feed generically needs no change; one that keys off the field or code to highlight a specific input must switch both tokens. TheSpree.t('checkout_requirements.shipping_method_required')translation key was renamed tocheckout_requirements.delivery_method_required— override it under the new key. - “Logic between steps” has no backend home by design. Side effects hang off data writes (workflow hooks such as
Carts::Complete’sbefore_finalizeandCarts::AddItem’safter_item_added) and events (cart.updated,order.placed) — not step transitions.
Statuses: derived, then persisted
payment_state and shipment_state machine columns are replaced by payment_status and fulfillment_status — stored, indexed, and recomputed from payment/refund/fulfillment records by a single writer, Spree::Orders::UpdateStatuses. The legacy names remain as read aliases for one release.
Behavior to review:
- Nothing else writes these columns. If your code assigned
order.payment_state = 'paid', replace it with the underlying records (payments/refunds) and let the recompute derive. - The
payment_statusdomain gainsoverchargedandvoided;fulfillment_statusincludesbackorder. Money comparisons are quantized to currency precision and refunds are netted before comparing. - A completed payment updates only the payment side of the ledger (
payment_total+ statuses). It never re-sums item or adjustment money. - Fulfillment is a fact: a fulfilled fulfillment is never downgraded by later payment-state changes.
Recalculation on write
Transition-triggered recalculation is gone with the machine. Instead:Spree::Carts::RecalculateTotalsis the single totals seam: money inputs, typed-row regeneration (promotions via the winner-only adjuster, tax via the market’s tax provider, falling back toSpree.default_tax_provider), folding and one persist. It runs on the writes that matter — item changes, address/market changes (which also re-price items and rebuild delivery proposals) — not on step transitions.- Promotion eligibility is evaluated against current totals in the same recalculation — a cart crossing a coupon threshold gets the discount on that recalculation, not the next one.
- Completed orders are money-frozen. Typed rows are never regenerated post-placement; recalculation only re-sums them. Post-placement money edits go through the explicit admin services (
Orders::Discounts::*,Orders::Fees::*), which write rows and re-sum. Spree::OrderUpdaterandSpree::CartUpdaterremain as deprecated shells — every method warns and runs the full recalculation. Removed in 6.1.
Completion, in one workflow
Spree::Orders::Complete is the one home for everything that happens when an order becomes placed — payment processing (when needed), fulfillment finalization, placement, coupon/gift-card redemption, digital auto-fulfillment, statuses, and the order.placed event. Checkout reaches it through Carts::Complete; admin/B2B draft completion calls it directly (payment_pending: true places without processing payments for invoice-later flows).
Order#finalize!is deprecated (removed in 6.1) and delegates to the workflow. Behavioral change: finalizing an already-completed order is now a no-op — the workflow halts idempotently instead of re-running side effects.- Completion side effects moved out of the model. Newsletter subscription, checkout account creation and risk assessment run in the synchronous
Spree::OrderPlacedSubscriberon theorder.placedevent. Decorators that patchedfinalize!should become event subscribers orbefore_finalizehook handlers. order.placedis the completion event.order.completedstill fires as a deprecated alias for one release (webhook consumers should migrate; wildcard subscribers can dedupe on thedeprecated_alias_ofmetadata marker).Order.register_update_hookno longer runs during completion.
Addresses
The full address surface is shared by Cart and Order throughSpree::Purchase::Addresses, which means cart checkout regains behavior that 5.x orders had:
- Address writes deduplicate against the customer’s address book and promote checkout addresses to the customer’s defaults (quick-checkout wallet addresses excluded).
ship_address_id=/bill_address_id=are ownership-guarded: an address not owned by the record’s customer resolves tonil.- A signed-in customer entering checkout gets blank address slots auto-filled from their saved defaults.
use_billingis deprecated (removed in 6.1): the shipping address is canonical — useuse_shippingto copy ship → bill.- The
firstname,lastnameandzipcodecolumns are renamed tofirst_name,last_nameandpostal_code. The API has used the new names since 5.4, but two things carried the old ones and now change: validation error keys (a client sendingpostal_codeused to get its error back underzipcode) and Ransack filter keys (q[zipcode_cont]becomesq[postal_code_cont]). The old method names still read for one release.
Returns, exchanges and claims
TheReturnAuthorization → CustomerReturn → Reimbursement chain is replaced by three first-class records that each belong directly to an order: Spree::Return (items come back, money goes back), Spree::Exchange (items come back, different items go out), and Spree::Claim (something went wrong in delivery — no items required back).
The old classes are gone with no bridge, so calls raise NameError:
The legacy tables are not dropped. They stay through 6.1 as the data migration’s source and rollback path. Run
spree:upgrade:migrate_returns to copy the history onto the new models — it’s resumable, and it aborts if any row fails so an upgrade can’t silently proceed on partial history.No state machines
New records carry a plainstatus string with an inclusion validation. Every transition is a workflow — Spree::Returns::Approve, Returns::Receive, Returns::Refund, Exchanges::Fulfill, Claims::Resolve, and so on. Nothing happens in a model callback or transition callback, so code that hooked before_transition on the old machines has no equivalent; move it to a workflow hook or an event subscriber.
Statuses are extensible but additive only, through Spree::HasStatus:
Eligibility is a hook, not a validator chain
ReturnItem::EligibilityValidator::Default chained five validators. Only one was policy; the rest were invariants, so they moved to different places:
Core ships exactly one policy rule — a return window read from
market.preferred_return_window_days (default 30), per market because return windows are a regional legal matter. Staff can override it: when created_by is present the window is advisory, so a supervisor can accept a late return without a code change.
Replace it by swapping the handler:
order, items, created_by, order.market) and calls workflow.reject!(message) to veto. Every one of the fifteen transitions has a leading validate hook, so the same seam gates approving, receiving and refunding.
Other behavioral changes
Return#refunded_totalcounts store credits. Store credit is a separate ledger and never creates aSpree::Refundrow, so the old sum reported zero for a store-credit refund.Order#outstanding_balancedropped its reimbursement term rather than replacing it — refunds already net out ofpayment_total, so that term was double-counting.- The reimbursement email is now
Spree::ReturnMailer#refunded_email, sent onreturn.refunded(in the optionalspree_emailsgem, like the other transactional mail). Refund#originatorpoints at the new records.- Events are
return.requested/.approved/.received/.refunded/.canceled, and the matchingexchange.*andclaim.*families. - Digital downloads redirect instead of streaming. The download endpoint now answers
302with a short-lived signed URL rather than sending the file bytes directly, so a large download no longer occupies a web worker. Clients that read the response body must follow redirects (most HTTP clients and every browser already do). The link’s own lifetime is unchanged;digital_asset_link_expire_time(default 300 seconds) — previously unused — now sets how long the signed URL stays valid, and is capped at one hour because that URL is a bearer credential. The signed URL is additionally clamped so it can never outlive the download link that issued it. - Download limits can be set per asset.
authorized_clicksandauthorized_dayson a digital asset override the store’s download settings; left blank, the store settings apply as before. - A successful download publishes
digital_link.downloaded, so downloads are visible to webhooks and subscribers for the first time. - Customers are emailed their download links when the order is placed, from the new
Spree::DigitalAssetMailerin the optionalspree_emailsgem. Hosts that already send their own download email should either suppress it (send_consumer_transactional_emails) or drop their own. The dashboard’s order page can re-send it. - Digital assets are managed through the Admin API and dashboard, and signed-in customers can list everything they have bought at
GET /api/v3/store/customers/me/digital_links.
Free-gift promotions price their own gift
TheCreate line items promotion action now discounts the items it adds down to zero. It used to add them at their full price and leave the discounting to you.
Two further changes come with it. A customer who already has the gift variant in their cart now gets theirs free rather than a second copy being added, and the promotion applies where it previously did nothing at all. And a gift is only taken back out of the cart for promotions actually applied to that order, so a customer who bought the gift variant themselves without qualifying keeps it.
A gift also no longer counts toward spend thresholds or toward the amount a percentage-off order discount is taken from. That includes the gift promotion’s own threshold, so a “spend over a set amount, get a gift” promotion now takes the gift back once the paid items fall below the threshold.
Dependency injection changes
6.0 introduces*_workflow keys for the flows that graduated to the workflow tier. The old *_service keys stay settable and readable one release so applications don’t crash at boot — but a legacy write is stashed, not applied: a class written against the old service contract is not interchangeable with the workflow the new call sites consume. Reads return your stashed class (legacy code calling its own override keeps working), falling back to the workflow. Removed in 6.1.
New seams with no legacy counterpart:
Removed keys (their classes no longer exist):
carts_validate_service (completion validation is Spree::Checkout::Requirements directly), plus the dead legacy Spree::Cart::* service namespace registrations.
If you override a workflow seam, subclass the shipped workflow (or implement the same perform keyword contract) — workflow arguments are plain Ruby keywords, so a mismatch raises ArgumentError at call time, not silently.
Cancelling an order is final
Order#resume, Order#resume!, Spree::Orders::Resume, the
order_resume_workflow dependency key, the orders.resume hooks, the
order.resumed event and PATCH /api/v3/admin/orders/:id/resume are all
removed. A canceled order stays canceled.
Resuming only ever flipped the status back while leaving the cancellation
behind — the timestamp, who did it and why — so a resumed order went on
reporting a cancellation it was no longer in. No comparable platform offers
the operation at all: Shopify calls cancellation irreversible, and Vendure
and Saleor make the state terminal by construction. Where an order needs to
live again, place a new one; the canceled record stays as the history of what
happened.
The same applies one level down. Spree::Fulfillments::Resume, the
fulfillment_resume_workflow key, the fulfillment.resumed event and
PATCH /api/v3/admin/orders/:id/fulfillments/:id/resume are removed, and a
canceled fulfillment can no longer be fulfilled either — Fulfillment#can_fulfill?
is false once canceled. Cancelling a parcel ends that attempt to ship, not
the obligation: its units are offered to the next fulfillment you create for
the order, which promises the stock afresh. This is how Shopify and Medusa
recover from a mistaken cancellation too — a new fulfillment, never a revived
one. An order whose every parcel was recalled now reads
fulfillment_status: unfulfilled rather than canceled; only a canceled
order reads canceled.
Payment source IDs use the psrc_ prefix
Spree::PaymentSource shared the ps_ prefix with Spree::PaymentSession,
so the same string could name either record. Payment sources now use
psrc_. The encoded part of the ID doesn’t change, only the prefix. The only
place a payment source’s ID shows up is the source_id of a payment backed by
a non-card payment source (wallets, bank redirects and similar). If you stored
one of those values, swap ps_ for psrc_. Payment session IDs stay ps_.
Class-level decode_prefixed_id (for example Spree::Product.decode_prefixed_id)
now returns nil for an ID with another model’s prefix, the same as
find_by_prefix_id. Call Spree::PrefixedId.decode_prefixed_id if you
really need to decode any prefix.
Removed in 6.0
These were deprecated in 5.x and are gone now — there is no bridge, so calls raiseNoMethodError. Most were one-line delegations to a replacement that already exists.
Custom fields replace metafields
The metafields system is now custom fields:Spree::CustomField and Spree::CustomFieldDefinition, stored in spree_custom_fields and spree_custom_field_definitions. The legacy class names, the concern, and the reader methods all keep working for one release with a deprecation warning (see the table further down), so most applications need no code change to upgrade.
Two things do change under you, both handled by db:migrate:
- Visibility is a boolean. The tri-state
display_oncolumn collapses intostorefront_visible. Definitions that wereback_endbecomestorefront_visible: false; everything else becomestrue. Thefront_end-only value never meant hidden-from-staff and folds intotrue. - Two columns are renamed.
namebecomeslabel, andmetafield_typebecomesfield_type. Readingfield_typereturns the API token (short_text) rather than the Ruby class name; usefield_type_class_namewhen you need the class. - Rich-text values leave Action Text.
Spree::CustomFields::RichTextstores sanitized HTML in the samevaluecolumn as every other type, so readingvaluereturns a String rather than anActionText::RichText. Existing bodies are copied across by the migration; the Action Text rows stay behind as a rollback path until 6.1. Code callingcustom_field.value.bodyshould readvaluedirectly.
Spree::Metadata no longer includes the custom-fields concern (metadata is the private, schemaless system — include Spree::HasCustomFields explicitly if a model needs both), and rich-text values are now sanitized on save, so markup outside the allowlist is stripped rather than stored.
BREAKING — product CSV. Custom-field columns are now prefixed custom_field. instead of metafield. (for example custom_field.custom.material). Exports emit the new prefix and imports only recognise the new prefix, so update any saved import templates and any integration that reads the export. A file still using the old prefix imports without its custom-field values rather than failing.
Definitions now belong to a store. They used to be global, shared by every store in an installation. db:migrate assigns every existing definition to the default store and gives it a filter_key column (the cf_… identifier that used to be recomputed on every read). Read them through the store — store.custom_field_definitions — rather than the class; the admin endpoint is store-scoped, so a definition belonging to another store now returns 404 instead of being readable and editable.
Single-store installations notice nothing. A multi-store installation ends up with its whole schema on the default store, and its other stores start empty: create the fields you want on each store, or run bin/rake spree:upgrade:backfill_custom_field_definition_stores first if the migration ran before any store existed. Existing values keep pointing at the definitions they always did, which is why the rows are not copied — copies would render blank anyway. Two definitions whose namespace/key pair flattens to one cf_… key (("a_b", "c") and ("a", "b_c")) can no longer coexist in a store; the migration suffixes the later one and names it in its output so you can rename it.
The Legacy order-routing strategy is gone
Spree::OrderRouting::Strategy::Legacy — the pre-5.5 escape hatch that delegated straight to Spree::Stock::Coordinator and consulted no routing rules — is removed and no longer registered in Spree.order_routing.strategies.
A store or channel still carrying preferred_order_routing_strategy: 'Spree::OrderRouting::Strategy::Legacy' keeps working: Order#order_routing_strategy ignores unregistered classes, logs a warning, and falls back to Strategy::Rules. Clear the stale preference to silence the warning — note that saving such a record now fails validation, since the value is no longer in the registry.
Spree::Stock::Coordinator itself stays — cart fulfillment building, exchanges, and claims still use it.
belongs_to is required by default
Spree models followed Rails’ pre-5 rule, where a belongs_to was optional
unless you said otherwise. They now follow the modern default: a belongs_to
is required unless it is declared optional: true.
Two consequences for an application built on Spree:
- Your own models change with it. Anything inheriting from
Spree::Basepicks up the new default, so an association that is legitimately blank now needsoptional: trueon it. A model that relied on the old behaviour will start failing validation until you say which associations are optional. - The message changed. A missing association reports
"must exist"rather than"can't be blank". Code that matches on the old wording — a test, or a client readingdetailsout of a 422 — needs updating.
nil and saving it is the quickest way to
tell whether an association is required.
Store.default no longer builds a store
Spree::Store.default returned an unpersisted Store.new(default: true) when no default store existed. It now returns nil.
This matters more than it looks: Spree::Current.store falls back to Store.default, and every Spree::SingleStoreResource model resolves its own store from Spree::Current.store. Without a default store, those records now fail validation with “Store must exist” instead of silently attaching to a throwaway store.
Make sure a default store exists before creating store-scoped records, and set Spree::Current.store in jobs, rake tasks, and tests that run outside a request.
The DefaultPrice concern is gone — price_in / set_price is the interface
Spree::DefaultPrice and the enable_legacy_default_price setting are removed, along with the has_one :default_price association on Variant. Prices live in spree_prices, one row per currency, and a price is always an amount plus a currency — there is no longer an implicit “the” price.
The single-currency accessors are gone from both Variant and Product:
The
compare_at_price reader (which resolves against cost_currency) is unchanged on both Variant and Product — only the writer is gone. So are the price_in / amount_in / compare_at_amount_in readers.
Also note:
- Ransack:
default_priceis no longer a searchable association onVariant; querypricesinstead. - Prices in permitted params: the dead
:priceand:compare_at_priceentries are gone. Prices were already written as nestedprices: [{ amount:, currency: }]under variants — the top-level keys had no writer behind them. (Spree::PermittedAttributesitself is removed in 6.0 — see below.) - Localized number parsing still happens:
Spree::Price#amount=runsSpree::LocalizedNumber.parse, soset_price(currency, '1,599.99')works asprice=did. - The variant validation that inferred a missing price from the product’s default variant is gone. Set prices explicitly (the product and variant factories already do).
Spree::PermittedAttributes is removed
The global permitted-attributes registry is gone, with no deprecation bridge. It
existed so the Rails admin and storefront could share one allowlist; both are
removed in 6.0, and API v3 declares its attributes in the controller.
Removed alongside it: Spree::Core::ControllerHelpers::StrongParameters (the
permitted_*_attributes helper methods it mixed into controllers) and the
fallback that inferred an attribute list from the model name.
Attributes you pushed from an initializer are now declared on the model, and
standard resource endpoints append them to their own allowlist — so one
declaration still covers the model’s create and update endpoints.
How to migrate
1. Find every call site. The constant and the helper methods are both gone, so a missed reference raisesNameError or NoMethodError the first time that
code runs — loudly, but not necessarily at boot:
permitted_*_attributes method per registry key, so there were dozens of them.
2. Decide what each attribute actually is. Most fall into one of three
buckets, and only the last needs this hook:
3. Point each declaration at the model. It stays in your initializer — only
the receiver changes, from the global registry to the model itself:
config/initializers/spree.rb
+=, not = — the list is per model, and assigning replaces whatever
another extension already added. << raises a FrozenError: the default is a
frozen shared array, so mutating it in place would leak your attribute onto every
other model.
Declare only attributes of your own. Redeclaring a key the controller already
permits (metadata, prices) does not widen it — strong parameters keeps the
last filter for that key, so the controller’s own would be replaced by yours.
Entries are params.permit fragments, so collections and nested structures keep
the shapes you already know: [:brand_id, { region_ids: [] }].
4. Fix your own controllers. If you subclassed a Spree v3 resource
controller and relied on the attribute list being inferred from the model name,
declare it now:
resource_permitted_attributes nor permitted_params raises
NotImplementedError on the first write, so this surfaces in your test suite
rather than silently permitting a stale list.
5. Verify a write actually persists. A declaration that never reaches a
controller fails silently — strong parameters drop the unpermitted key, the
request still returns 200, and the column keeps its old value. Assert on the
saved record, not the response status:
permitted_attributes — that method is where
the extension attributes are appended, so overriding it replaces them. Override
resource_permitted_attributes instead.
STI types registered through a Spree registry (promotion rules and actions,
delivery method rules, commission rules) already used
additional_permitted_attributes and need no changes — the hook simply moved up
to Spree::Base.
StateChange and LogEntry are gone
Spree::StateChange and Spree::LogEntry are removed — the models, the state_changes associations on Order, Payment and Fulfillment, the log_entries associations on Payment and Refund, and everything that wrote to them. Both were write-only: nothing in Spree read the rows back, and the admin screens that displayed them are gone.
Events are the audit trail now. Instead of querying state-change rows, subscribe to the lifecycle events that already fire on every meaningful transition: order.placed, order.canceled, payment.completed, payment.voided, fulfillment.fulfilled, fulfillment.delivered, fulfillment.canceled, and the rest. If you need a persistent history, write it from a subscriber.
Gateway responses are no longer stored in your database. LogEntry kept every gateway response as serialized YAML. For transaction forensics, use your payment provider’s dashboard — Payment#gateway_dashboard_payment_url links straight to the transaction — or Spree::PaymentSession, which holds the gateway-side state for session-based providers.
The spree_state_changes and spree_log_entries tables are not dropped until 6.1, so your existing rows survive the upgrade. If you want the history long-term, export it before upgrading to 6.1.
Spree::Report is gone
Spree::Report, its Reports::SalesTotal and Reports::ProductsPerformance subclasses, Spree::ReportLineItem and its subclasses, Spree::ReportMailer, Spree::ReportSubscriber, Spree::Reports::GenerateJob, the Spree.reports registry and the reports queue entry are all removed, along with the reports associations on Spree::Store and the admin user.
Nothing in 5.6 could reach it: there was no API endpoint and no admin screen, so a report could only be created from Ruby.
Where each question goes now. Aggregates — revenue by channel, product performance, anything you would have written a report class for — are reporting queries, composed from registered metrics and dimensions rather than a class per question. Row-level CSV of records is a Spree::Export subclass, which is what imports and exports covers, and unlike Spree::Report it has both an Admin API and a dashboard page.
Your rows are safe. The spree_reports and spree_report_line_items tables are left exactly as they are — nothing reads them, and no migration drops them. A store installing 6.0 fresh never creates them. If you want the old report history, export it whenever suits you; there is no deadline.
If you subclassed Spree::Report in your own application, that class no longer has a superclass and will raise on boot. Move the question to one of the two replacements above.
Emails render from Liquid templates
Every transactional email now renders from a Liquid template written in MJML instead of an ERB view. Templates read serializer data rather than models, keep their Rails view paths, carry their subject line, and get a plain-text version generated from the HTML. See Email Templates for how they work. If your app overrides no emails, there is nothing to do. The emails look the same. If your app overrides Spree’s emails with ERB, those files are no longer used and Spree sends its default design instead, without a warning. Look for them underapp/views/spree/*_mailer/ and app/views/spree/shared/, and port each to the .liquid file at the same path:
Other changes that come with it:
- Subjects moved into the templates. Each template’s front matter holds its subject.
Spree::BaseMailer#order_email_subjectis removed. - The mailer view helpers are removed.
Spree::MailHelper(name_for),Spree::BaseHelper(spree_storefront_resource_url),Spree::FulfillmentHelperandSpree::DigitalAssetHelperare gone, and so arespree_image_tagandspree_asset_aspect_ratiofromSpree::ImagesHelper(spree_image_urlstays). Templates get the same values as serializer fields, such asorder.customer_nameanditem.url. - Every email has a plain-text part, generated from the HTML. Seven emails that had none now have one.
- Alba moved into
spree_core, with its configuration, so core’s staff emails render in installations withoutspree_api. - Your own mailers keep working. A mailer that inherits
Spree::BaseMailerand callsmailwith its own ERB views is wrapped in the new email layout, and thespree/shared/mailer_heroandmailer_buttonpartials remain for its views. See Your own mailers.
Deprecated in 6.0, removed in 6.1
Every rename keeps the legacy name working for one release with a deprecation warning. The notable ones: Before 6.0 a cart was an incomplete order, so extensions written for 5.x (payment gateways in particular) call these names on carts. The rows marked Order and Cart are answered bySpree::Cart as well as Spree::Order, with the same warning.
Meilisearch moved to its own gem
Meilisearch is no longer part ofspree_core. Stores using the Database provider (the default) need to do nothing.
If you set Spree.search_provider to the Meilisearch provider, add the gem and update the class name:
MEILISEARCH_URL and MEILISEARCH_API_KEY settings are unchanged, and no reindex is needed — the documents have the same shape. Applications that subclassed the document presenter should inherit from SpreeMeilisearch::ProductPresenter instead.
The old class names keep working for one release, so an application that only names them in an initializer boots with a deprecation warning rather than an error — but the gem must be installed either way, since the classes now live there.
Staff permissions: roles are data
Permission sets are removed with no bridge. Staff roles now hold flat permission keys from one catalog — the sameread_<resource> / write_<resource> vocabulary secret API keys use — and are managed as data: in the dashboard (Settings → Roles), through the Admin API (/api/v3/admin/roles), or in seeds. Code no longer defines what a role can do.
Old initializers fail loudly at boot: the set constants raise
NameError, and Spree.permissions.assign raises with directions. Recreate your custom roles before deploying — a pre-existing role row comes up with no permissions, so staff holding it are locked out (fail closed) until its permissions are filled in:
:default role and DefaultCustomer set are gone, customers never resolve roles, and the Store API no longer consults CanCanCan at all — customer authorization is ownership-scoped queries plus Spree::Storefront::AccessPolicy (swappable via Spree::Dependencies.storefront_access_policy_class). If an extension added storefront can rules, move them into an access-policy subclass or a checkout workflow validate hook. Extensions register their resources into the catalog once, which makes them grantable to roles and mintable as API-key scopes:
- JWT staff pass the same per-controller key gate as secret keys. A request whose principal lacks
<read|write>_<resource>gets a 403 withdetails.required_permissionnaming the missing key. - Staff endpoints moved out of the
settingsscope./admin_users,/invitations, and/rolesnow require the newread_staff/write_staffscopes; secret keys minted withsettingsbefore 6.0 lose those endpoints./countriesand/localesbecame scope-exempt reference data.
Rich-text fields read as plain text plus HTML
Writes are unchanged.description and internal_note still take the value, and that value is still HTML — the same as 5.6, so no integration needs updating.
Reads are where 6.0 differs. Every rich-text field now returns both shapes:
Two consequences worth checking in your own code:
- Hydrate editors from
*_html. The plain field is tag-stripped, so binding an editor todescriptionand saving it back would flatten the markup on every save. - The field holds HTML. That is true of every writer — the Admin API, CSV import, the console — so send markup, not plain text with newlines in it.
For extension authors
- Don’t reach for model business methods from services — 6.0 code style writes behavior inline in service/workflow steps; models keep data, validations, predicates and persistence primitives. Extensions patching removed model methods (
finalize!internals, updater hooks) should move to workflow hooks (Spree.hooks.register('carts.complete.before_finalize') { |flow| ... }— handlers receive the workflow instance) or event subscribers. - Store-scoped data is single-owner.
Product,PromotionandPaymentMethodbelongs_to :store; thestores: [...]writers are gone. Multi-store sharing lives in thespree_multi_storeextension. - The Store API v3 contract is stable across the split — cart endpoints keep their shapes;
requirementsgainscode(and renames the delivery entry’s field/code todelivery_method, see above), and serializernumberon carts is bridged as described above.

