Skip to main content
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.
Spree 6.0 is a major release. The headline changes:
  • Cart and Order are separate models. Spree::Cart owns shopping and checkout; completing checkout copies the cart into an immutable Spree::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::Adjustment is replaced by Spree::TaxLine, Spree::Discount and Spree::Fee.
  • Master Variant is gone, replaced by a default_variant_id foreign key on Spree::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 pluggable FulfillmentProvider strategy.
  • Two-tier services. Plain services in app/services, plus Spree::Workflow classes in app/workflows for the curated multi-step flows (completion, cancellation, recalculation) — with named steps, instrumentation and extension hooks.
The upgrade is completed in four steps:
  1. Update the Ruby gems
  2. Run database migrations
  3. Run data backfills — convert your existing records into the new schema
  4. Review behavioral changes — this release changes runtime behavior, not just schema

How to upgrade

Before running 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.
Re-running is safe — 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 backfills bundle exec rake spree:upgrade executes. Every task is idempotent.

Store preferences as JSON

Preferences were stored as YAML; in 6.0 they are JSON. The migration converts every preferences column itself, and moves gateway and integration secrets (preferences declared as :password) into a new secret_preferences column. The first task is a safety net for anything the migration could not reach — a schema changed by hand, or an extension gem installed after the migration ran. The second encrypts the moved secrets; until it runs they stay readable and are encrypted the next time their payment method or integration is saved. It does nothing until encryption keys are configured. See Preferences are stored as JSON for what changes in your code.

Convert incomplete orders into carts

Every order that never completed and was never canceled becomes a 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

Rebuilds 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

Moves stored strings, statuses and class names to the fulfillment/delivery vocabulary, converts delivery-referenced Spree::Zone records into Spree::DeliveryZone with typed members, and converts FlatRate calculator eligibility bounds into Spree::DeliveryMethodRule records.

Remove master variants

Products now carry a default_variant_id foreign key; is_master is gone from the models. (The physical column drop lands in 6.1.)

Categories and collections

Taxons become 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

Category and collection descriptions, policy bodies, and order and customer internal notes move from 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:
The Action Text rows are left in place as a rollback path and are dropped with the tables in 6.1. Once this has run, Spree no longer loads Action Text. 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

A store without a market gets a default one covering its old checkout zone’s countries (or its default country), and the checkout zone is cleared. Orders missing a market then get the store default — 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

Orders, refunds, returns, exchanges, claims and stock receipts remember who cancelled, approved, created or received them. In 6.0 that can be a member of staff or an API key, so each id column — 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

Copies your legacy Devise customer table (default 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.
Passwords carry over only if your application never configured a Devise pepper. The task stops if it finds one. Because Devise is no longer loaded in 6.0, the task usually cannot check for itself — it stops until you confirm no pepper was used by re-running it with CONFIRM_NO_PEPPER=true. When there are no passwords to carry over, for example an empty spree_users table, the task skips this check. If you did use a pepper, single sign-on, or a non-bcrypt password format, skip this step and keep your own customer model by setting Spree.customer_class.

Name countries and states by ISO code

Countries and states are no longer database records in 6.0. Addresses, delivery zone members, market countries, stock locations and stores now name them 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.

Record which store owns every uploaded file

Every uploaded file now records the store that owns it (active_storage_blobs.store_id). New uploads get it when they are made; this task gives existing files the store of the record they are attached to. A file attached in more than one store is listed and left alone for you to resolve. Safe to re-run at any time.

Enable Active Record encryption

Spree encrypts payment gateway and integration secrets, 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 from spree-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
Setting the keys in 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:
Recreate the containers so they pick up the new .env (spree dev — spree restart keeps the old environment). Set the keys on your production host before deploying, and store them in your secret manager.
Never change or lose the keys once data is encrypted — the encrypted records become unreadable.
3. Encrypt existing rows. Rows written before the keys were set are plain text. Gateway and integration secrets and the OAuth tokens on user identities stay readable and are encrypted on their next write; run spree rake spree:upgrade:encrypt_secret_preferences (bundle exec rake spree:upgrade:encrypt_secret_preferences without the Spree CLI) to encrypt every gateway and integration secret at once. 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.

Preferences are stored as JSON

Model preferences moved from YAML to JSON, and secrets moved out of them. Declarations do not change, and the preferred_* methods return the same values as before. What can affect your code:
  • The raw preferences hash has string keys. It still answers to symbols (calculator.preferences[:amount]), but preferences.keys returns strings. Decimals sit in it as exact strings ("9.99"); read them through preferred_*, which returns a BigDecimal.
  • Secrets are not in preferences. Preferences declared :password live in the encrypted secret_preferences column. Read them with preferred_* or get_preference. A secret you assign as part of a whole hash (update(preferences: { secret_key: ... })) is moved there when the record is saved. To declare a secret on a model other than a payment method or an integration, see Secret preferences.
  • Your own preference tables need a JSON column. The migration converts every table a model storing Spree preferences reads, including your application’s own; a table another library owns is left alone. A new table should use t.jsonb :preferences on PostgreSQL, the default database (t.json on MySQL or SQLite).
  • Tiered calculators take a list of tiers. Spree::Calculator::TieredPercent and TieredFlatRate store tiers as a list instead of a hash keyed by threshold, in Ruby and in the Admin API. The migration converts existing calculators.
    value is a percentage for TieredPercent and an amount for TieredFlatRate.
  • Spree no longer allows extra classes in YAML columns. It used to add Symbol, BigDecimal and a few time classes to config.active_record.yaml_column_permitted_classes. If your application serializes its own columns as YAML with those classes, add them in config/application.rb yourself.
  • Preference change methods follow Rails’ naming. Each preference is a Rails store accessor, so preferred_<name>_changed?, preferred_<name>_change and preferred_<name>_was work as before, while the after-save forms are Rails’ own: saved_change_to_preferred_<name>? replaces preferred_<name>_previously_changed?, saved_change_to_preferred_<name> replaces preferred_<name>_previous_change, and preferred_<name>_before_last_save replaces preferred_<name>_previously_was.
  • Per-preference helper methods are gone. Use preference_type(:name), preference_default(:name) and preference_deprecated(:name) instead of preferred_<name>_type, preferred_<name>_default and preferred_<name>_deprecated. clear_preferences, restore_preferences_for, preferences_of_type, the class methods preference_getter_method, preference_setter_method and prefers_query_method, Spree::Preferences::ScopedStore, Spree::Preferences::Store and the Spree::Preference model are removed.
  • The spree_preferences table is dropped. The installation id it held is not carried over: the default store generates a new one the next time it is saved. Rows an application wrote there itself are dropped with it: copy any you need before upgrading.
  • Preferences live only on models. Spree::Preferences::Preferable is included by Spree::Base and needs a preferences JSON column; including it in a plain Ruby class is no longer supported. A hash stored inside a preference comes back with string keys.
  • A record whose preferences still hold YAML cannot be read. The migration converts every row, so this only happens to rows written by older code after it ran. Run spree rake spree:upgrade:preferences_json (bundle exec rake spree:upgrade:preferences_json without the Spree CLI) to convert it.

The Cart/Order split

The single biggest change. What used to be one Spree::Order living through checkout and beyond is now two models:
  • Spree::Cart (spree_carts, prefixed IDs cart_...) owns the shopping and checkout phase: line items, addresses, payments-in-progress, delivery proposals, promotions, stock reservations. Carts have no status column — completed_at is the only lifecycle marker.
  • Spree::Order is created by completion — the cart is copied into it (line items, fulfillments, addresses, typed money rows — copies, never shared rows). Orders carry status (draft / placed / canceled) and are money-frozen once placed.
Consequences to review:
  • 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::Complete guards with a unique spree_orders.cart_id index and a completing_at lock: 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 nullable cart_id + order_id with an exactly-one rule and an #owner method. Code that assumed line_item.order is always present must read line_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 targeting Spree::Order methods 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_step and completed_checkout_steps are 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::Complete is 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::Registry is the extension surface. One declaration serves both the advisory feed and the completion gate:
Register at the top level of the initializer, not inside 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 requirements array now carries a stable code on every entry (email_required, out_of_stock, guest_checkout_not_allowed, …). Additive change — existing consumers keep working.
  • The delivery requirement is keyed by its code, delivery_method_required. Its entry is { step: 'delivery', field: 'shipping_method', code: 'delivery_method_required' }. The field keeps its 5.6 name until 6.1, when it becomes delivery_method, so a 5.6 storefront that matches the field keeps working. Match the code instead, which does not change. The spree.checkout_requirements.shipping_method_required translation key was renamed to spree.checkout_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’s before_finalize and Carts::AddItem’s after_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_status domain gains overcharged and voided; fulfillment_status includes backorder. 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::RecalculateTotals is the single totals seam: money inputs, typed-row regeneration (promotions via the winner-only adjuster, tax via the market’s tax provider, falling back to Spree.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::OrderUpdater and Spree::CartUpdater remain 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::OrderPlacedSubscriber on the order.placed event. Decorators that patched finalize! should become event subscribers or before_finalize hook handlers.
  • order.placed is the completion event. order.completed still fires as a deprecated alias for one release (webhook consumers should migrate; wildcard subscribers can dedupe on the deprecated_alias_of metadata marker).
  • Order.register_update_hook no longer runs during completion.

Addresses

The full address surface is shared by Cart and Order through Spree::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 to nil.
  • A signed-in customer entering checkout gets blank address slots auto-filled from their saved defaults.
  • use_billing is deprecated (removed in 6.1): the shipping address is canonical — use use_shipping to copy ship → bill.
  • The firstname, lastname and zipcode columns are renamed to first_name, last_name and postal_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 sending postal_code used to get its error back under zipcode) and Ransack filter keys (q[zipcode_cont] becomes q[postal_code_cont]). The old method names still read for one release. The Admin and Seller APIs no longer accept firstname and lastname in an address payload: send first_name and last_name.
The Admin and Seller APIs drop their deprecated fields in 6.0. The Store API keeps its deprecated fields until 6.1, marked deprecated in the API reference and the SDK types; the Admin and Seller APIs remove theirs now:

Returns, exchanges and claims

The ReturnAuthorization → 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 plain status 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.
Four returns settings are deprecated and read by nothing — they still exist so applications don’t crash at boot, but they have no effect and now emit a deprecation warning when read or written:
Replace it by swapping the handler:
Core registers the default validator before your initializers load, so the swap works at the top level of an initializer. A handler receives the workflow (so it can read 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.
requires_manual_intervention? has no equivalent. The old validators could mark an item eligible-but-flagged for manual review; a handler now either accepts or vetoes. If you relied on that middle state, model it explicitly — an added status, or a metafield your handler sets.

Other behavioral changes

  • Return#refunded_total counts store credits. Store credit is a separate ledger and never creates a Spree::Refund row, so the old sum reported zero for a store-credit refund.
  • Order#outstanding_balance replaced its reimbursement term with refunds made for returns, claims and exchanges (returned_items_refund_total). Those refunds net out of payment_total, and adding them back is what keeps a refunded return from reading as a balance due. A refund made for nothing in return still reads as owed, as in 5.x.
  • The reimbursement email is now Spree::ReturnMailer#refunded_email, sent on return.refunded (in the optional spree_emails gem, like the other transactional mail).
  • Refund#originator points at the new records.
  • Events are return.requested / .approved / .received / .refunded / .canceled, and the matching exchange.* and claim.* families.
  • Digital downloads redirect instead of streaming. The download endpoint now answers 302 with 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_clicks and authorized_days on 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::DigitalAssetMailer in the optional spree_emails gem. 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

The Create 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.
Delete the second action if you paired one with it. The old guidance was to add a whole-order discount set to the gift’s price to pay for the gift. That discount still applies, so a promotion configured the old way now discounts twice — the gift is free and the order discount comes off. Open each promotion using Create line items, and remove the accompanying Create whole-order adjustment that exists only to cancel out the gift.
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), cart_empty_service and payment_create_service (see Unused services are gone), 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.

File uploads

POST /direct_uploads is removed from the Admin and Seller APIs. Upload through POST /files instead (Uploads): the request is flat (filename, content_type, byte_size, checksum, visibility), the bytes go to upload.url, and small files can be sent in the request itself. In the SDKs, directUploads.create() becomes files.upload(file, { visibility }); private: true becomes visibility: 'private'. The signed_id an upload returns now expires after a day (expires_at). Store the file’s record, not the signed_id. Every parameter that takes a signed_id is renamed to <name>_signed_id: The Store API’s POST /carts/:id/po_document keeps its route but takes the same flat request and returns the same response as POST /files. Uploads that are never attached are now deleted after two days. Only files uploaded to a store are deleted; files from before the upgrade that were never attached are left as they are. Schedule the job daily in server/config/recurring.yml:
server/config/recurring.yml
Attaching a file uploaded in another store is logged in 6.0 and refused from 6.1. If your logs show Spree 6.1 refuses this attachment, fix the code that creates that file before upgrading to 6.1.

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.

Type values are short names, not class names

The API no longer sends or accepts Ruby class names. Every value that used to name a class is now a short name (Spree::CreditCard becomes credit_card), so the API keeps the same shape whatever the server is built with. Records in the database are not touched. If your storefront or webhook receiver reads any of these fields, compare against the short name instead: The Admin API follows the same rule everywhere: providers and strategies (fulfillment_provider: 'manual', order_routing_strategy: 'rules'), custom field definition resource_type (product, category), tag taggable_type, the owner and originator types on records, the permission subjects /me returns (product, category), and filters such as q[type_eq]=orders. An integration written against the Admin API preview sends and expects these short names; the Admin SDK types list them.

Preferences are typed in the API

Settings no longer reach the Admin and Seller APIs in the shape of Ruby’s preference methods. The Store API is not affected. Settings use plain names. Every preferred_* field is renamed, on read and on write: order.preferred_stock_location_id keeps its name: it is an association, not a setting. Validation errors name the field without the prefix too. The Admin and Seller APIs no longer accept the old preferred_* names; send the new ones. The store’s storefront_url is the setting as saved, null when unset; url is the address customer links use. The store’s default_minimum_payout_amount and default_commission_tax_rate are now exact decimal strings ("0.23") instead of numbers. preference_schema is replaced by schema. Rules, actions, payment methods and integrations no longer carry preference_schema on every row. Each family’s /types endpoint (and the delivery-method and promotion-action calculator lists) returns a schema per type instead: the JSON Schema of its preferences, with format, enum, default and x-spree-secret. Read a row’s type, then look up its schema there. /payment_methods/types now lists every provider, with installed: true on those the store already has, instead of leaving them out. Writes are validated. A preferences payload with an unknown key, a value of the wrong type, a value outside the declared choices, or an id of another model or another store is refused with a 422 whose code is invalid_preferences. details names each failing value by its JSON pointer (/preferences/channel_ids/1, or /rules/0/preferences/channel_ids/1 inside a list of rules). Before, unknown keys were silently dropped. Send decimals as strings ("10.0") and record ids as prefixed ids (ch_…). Id lists read as prefixed ids. Preferences listing records, such as a rule’s channel_ids or customer_group_ids, now return prefixed ids rather than database ids, the same form a write accepts. Delivery methods nest their calculator. calculator_type and calculator_preferences are replaced by calculator: { type, preferences } on read and write, the shape promotion actions already use. The old two parameters are no longer accepted; their values follow the new rules (decimals as strings). A preference marked deprecated, such as a flat rate calculator’s minimum_item_total, is still accepted on write until its removal, with its deprecation warning, but is not read back. For extension authors: declare every preference’s full type (of: on arrays, keys: and values: on hashes, the :money type for amounts, choices:, format:). An incomplete declaration still works in 6.0 with a deprecation warning, and its schema accepts any value. Replace normalize_id_preference with preference :ids, :array, of: :id, model:, scope:, and in: with choices:. See Model preferences.

Every event is declared

Events are now declared on the model that publishes them (publishes_event, publishes_events), and publish_event raises in development and test for a name nobody declared. Extensions that publish their own events need one line each — see Publishing your own events. In production an undeclared event is logged and still delivered. Some payloads changed so that every webhook carries the Store API shape and nothing more:
  • notify_customer moved to metadata on order.placed, order.completed, order.canceled and order_group.completed. Read event.metadata['notify_customer'] in subscribers and metadata.notify_customer in webhook receivers.
  • customer.anonymized carries the customer record; the store and the requester moved to metadata (store_id, requested_by_id).
  • Purchase order, stock receipt, supplier and shipping label events carry a short payload — status, related record IDs and timestamps. Costs, notes, contact details and label files are read from the Admin API.
  • saved_report.* no longer includes the author’s name.
  • Subscribing an endpoint to customer.password_reset_requested needs the permission to manage customers. The staff and seller reset events are still never sent to webhooks.

Removed in 6.0

These were deprecated in 5.x and are gone now — there is no bridge, so calls raise NoMethodError. 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_on column collapses into storefront_visible. Definitions that were back_end become storefront_visible: false; everything else becomes true. The front_end-only value never meant hidden-from-staff and folds into true.
  • Two columns are renamed. name becomes label, and metafield_type becomes field_type. Reading field_type returns the API token (short_text) rather than the Ruby class name; use field_type_class_name when you need the class.
  • Rich-text values leave Action Text. Spree::CustomFields::RichText stores sanitized HTML in the same value column as every other type, so reading value returns a String rather than an ActionText::RichText. Existing bodies are copied across by the migration; the Action Text rows stay behind as a rollback path until 6.1. Code calling custom_field.value.body should read value directly.
Two behavior notes for extension authors: 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.

Unused services are gone

These had no caller left in Spree, so they are removed with no replacement shim. Setting or reading their Spree::Dependencies keys now raises NoMethodError.

Products no longer touch their categories

Saving or touching a product used to bump the updated_at of each of its categories and their parents, through Spree::Products::TouchCategoriesJob (formerly TouchTaxonsJob). That kept the old Rails storefront’s page caches fresh. The Store API doesn’t need it: a category’s response has no product data, and product responses are cached on the products’ own timestamps. Both jobs are removed. If your own code caches product data under a category’s cache_key_with_version, include the products’ timestamps in that key instead. Jobs still queued from before the upgrade fail with an unknown class error and can be discarded.

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 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::Base picks up the new default, so an association that is legitimately blank now needs optional: true on 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 reading details out of a 422 — needs updating.
Setting a record’s association to 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_price is no longer a searchable association on Variant; query prices instead.
  • Prices in permitted params: the dead :price and :compare_at_price entries are gone. Prices were already written as nested prices: [{ amount:, currency: }] under variants — the top-level keys had no writer behind them. (Spree::PermittedAttributes itself is removed in 6.0 — see below.)
  • Prices are written as numbers or canonical decimal strings: set_price(currency, '1599.99'). A localized string such as '1,599.99' raises Spree::Money::InvalidFormat (see Money).
  • 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 raises NameError or NoMethodError the first time that code runs — loudly, but not necessarily at boot:
The second pattern is deliberately broad: the helper module generated one 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
Use +=, 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:
Declaring neither 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:
If that assertion fails, the endpoint is not consulting your declaration. Check whether the controller overrides permitted_attributes — that method is where the extension attributes are appended, so overriding it replaces them. Override resource_permitted_attributes instead.
Two endpoints deliberately ignore the hook because their parameters are authorization data rather than resource data: API keys (scopes, key_type) and invitations (role_id). Adding attributes there needs a controller decorator, not a model declaration.
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 under app/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_subject is removed.
  • The mailer view helpers are removed. Spree::MailHelper (name_for), Spree::BaseHelper (spree_storefront_resource_url), Spree::FulfillmentHelper and Spree::DigitalAssetHelper are gone, and so are spree_image_tag and spree_asset_aspect_ratio from Spree::ImagesHelper (spree_image_url stays). Templates get the same values as serializer fields, such as order.customer_name and item.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 without spree_api.
  • Your own mailers keep working. A mailer that inherits Spree::BaseMailer and calls mail with its own ERB views is wrapped in the new email layout, and the spree/shared/mailer_hero and mailer_button partials remain for its views. See Your own mailers.

Money

  • Numbers are no longer parsed by locale. Price, variant, payment, refund, store credit, gift card, discount and fee amounts take a number or a canonical decimal string ("1234.56"); any other text raises Spree::Money::InvalidFormat, which the API answers with invalid_money_format. Before 6.0 they were re-read under the request’s language, so a store whose language writes a comma decimal (Dutch, German, French) saved the dashboard’s 49.50 as 4950, and an unchanged price grew tenfold on each save. Send canonical decimal strings ("1234.56") and convert localized input in the client. Spree::LocalizedNumber still works with a warning until 6.1. If your prices were saved in a comma-decimal store before upgrading, check them.
  • Rounding follows the currency. Taxes, percentage discounts, promotion splits and shipping markups round to the currency’s own decimal places from the ISO 4217 table: whole yen, three decimals for dinar. Two-decimal currencies are unchanged. Open carts in other currencies may change by less than one minor unit the next time they are recalculated. Placed orders do not change. Spree now gives the Hungarian forint two decimal places, as ISO 4217 does.
  • Money on the API is written to each currency’s decimals. Every amount in the Store, Admin and Seller APIs, and so in webhook payloads and email variables, is a decimal string with exactly its currency’s decimal places: "10.00" (was "10.0"), "1000" in yen (was "1000.0"), "1.500" in dinar. Unit prices keep up to four decimals. Fields that were JSON numbers are now strings: cart.order_minimum and order_minimum_shortfall, the product filter price range min/max, report money metrics and percentages, and the store’s default_minimum_payout_amount and default_commission_tax_rate. Clients comparing these strings, or parsing them as numbers, should switch to exact decimal arithmetic; the SDKs export sumMoney, subtractMoney, multiplyMoney, compareMoney and isZeroMoney.
  • Admin and Seller writes refuse JSON numbers for money. Amounts and rates, including those inside preferences, must be canonical decimal strings ("19.99"). A number, a localized or grouped string, or more decimals than the field holds is answered with 422 and invalid_money_format, naming the field.
  • Amounts may not carry more decimals than they can keep. Now that the columns hold four decimals, the database no longer rounds away a fraction of a cent, so a payment, refund, store credit, gift card, fee or discount with more decimals than its currency has (10.0049 dollars) fails validation with too_many_decimals. Unit prices and cost prices allow four, tax rates five decimals of the fraction (rate_percent "7.125", not "7.1255"), and price list percentages three. Round in your own code before saving. A manual order discount’s value and a commission rate’s value are read as strictly as other money fields.
  • The Admin and Seller APIs no longer return display_* money fields. Responses carry the amount ("29.99") with its currency, and the client formats it in the reader’s own language. Before 6.0 the server formatted a copy in the request’s language, which could disagree with the editable amount beside it. Format with Intl.NumberFormat or your platform’s equivalent. The Store API and webhook payloads keep their display_* money fields until 6.1, marked deprecated in the API reference and the SDK types; format the amount beside each one instead (total for display_total). Labels such as display_name are unchanged.
  • Money preferences have their own type. Calculator, rule and store preferences holding an amount (amount, first_item, minimal_amount, amount_min, min_amount, default_minimum_payout_amount and the like) are declared :money instead of :decimal, report money in preference_schema, and are written with the decimals of the calculator’s currency ("12.50" rather than "12.5"). Declare your own extensions’ amounts with :money so the dashboard shows their currency. A :decimal or :money preference now refuses text such as "1,599.99" instead of reading it as 1. Money preferences are checked by their type on write: a value that is not a decimal string, or has more decimals than its currency, is refused with invalid_preferences and a pointer to the field (/calculator/preferences/amount), rather than invalid_money_format.
  • Rates are decimal strings, and tax rates are renamed. Rates and percentages read and write as strings without trailing zeros. A tax rate’s amount (a fraction) is now rate, and amount_percentage is rate_percent; Spree::TaxRate#amount_percentage keeps working with a warning until 6.1.
  • amount_in_cents and compare_at_amount_in_cents are deprecated on Store API prices and price history, and removed in 6.1. They still hold the amount in hundredths, which is wrong for currencies without two decimals. Read amount, and each currency’s decimal_places (new on GET /api/v3/store/currencies) if you need minor units. The Admin and Seller APIs no longer return them.
  • Money columns widen to decimal(19,4). They now hold a Kuwaiti dinar’s third decimal, unit prices below a cent (0.0125), and very large amounts in currencies like the Vietnamese dong. No stored value changes. Totals, payments and refunds are still rounded to the currency’s decimals; only unit prices (price, compare_at_amount, cost_price, unit_cost) keep up to four. The migration rewrites each money table while it runs, which locks spree_orders, spree_line_items and spree_adjustments for as long as that takes. If you cannot take that lock, widen those columns online first (add a decimal(19,4) shadow column, backfill it in batches, swap it in), and the migration skips any column already at (19,4). Extensions with their own money tables, such as spree_stripe_payment_intents from the standalone Stripe gem, should widen theirs the same way.
  • Payment gateways receive the amount with its currency. authorize, purchase, capture and credit are called with a Spree::Money (amount and currency) instead of an integer in hundredths, which was wrong for yen and dinar. A gateway opts in with self.accepts_money_amounts = true and converts the amount to its provider’s unit (Stripe’s rules live in SpreeStripe::Units). A gateway that has not opted in still receives hundredths, with a deprecation warning, until 6.1. Spree.payment_capture_workflow.call(amount:) takes the amount in the currency’s own units; an Integer is still read as hundredths, with a warning.
  • Stripe refuses amounts it cannot take. Stripe charges three-decimal currencies (Bahraini, Jordanian, Kuwaiti and Omani dinar, Tunisian dinar) only in steps of 0.010, and pays out Hungarian forint and New Taiwan dollars only in whole units. spree_stripe now raises a gateway error naming the rule, rather than rounding to a sum other than the one the order records.
  • Capture records in yen and dinar written before 6.0 may be wrong. A capture in a currency without two decimals could be recorded in the wrong unit: 100 times too much in yen, a tenth in dinar. New captures are recorded exactly. To list the old records, writing nothing, run:

Locale codes come from a fixed list

A market’s default_locale and supported_locales, and the store’s own default_locale and supported_locales, accept only the codes in Spree::Locales::ALL: every ISO 639-1 language (en, de) plus the regional variants commerce needs (en-US, en-GB, pt-BR, zh-CN). The match is exact, so pt-br is refused in favour of pt-BR. Any other value is answered with 422 naming the codes Spree does not support. Since an order’s or cart’s locale must be one its store supports, it comes from the list too once the store’s locales do. Store setup, seeds and the markets migration move a code Spree does not list to its listed language (en-IE becomes en, zh becomes zh-CN). Values saved before the upgrade keep working until they are changed. If a market uses a code outside the list, change it to the listed spelling when you next edit it. The Store and Admin APIs publish the list as the LocaleCode type in the API reference and the SDK types. A store’s locales are content locales, independent of the languages anything ships translation files for: every listed code is accepted for content (product names, descriptions and other translated fields), while messages in a language Spree has no translations for fall back to English. An app that sets config.i18n.available_locales keeps that list as the limit. Store setup no longer limits the languages it offers to the ones Spree translates, and refuses a locale it cannot place with 422. Locale names move to the client. The name of a locale from GET /locales is deprecated in the Store API and removed in 6.1, and the Admin API no longer returns it: name a locale in the reader’s language with new Intl.DisplayNames([language], { type: 'language' }).of(code). Country and currency names move to the client the same way. A country’s name and a currency’s name and symbol are deprecated in the Store API and removed in 6.1, and the Admin and Seller APIs no longer return a country’s name: name a country with new Intl.DisplayNames([language], { type: 'region' }).of(iso) and a currency with { type: 'currency' }, and format amounts (which places the symbol) with formatMoney or Intl.NumberFormat. The Admin and Seller country lists are ordered by ISO code; sort them by the name you display. State and region names stay on the API, since Intl has none for them.

Translations ship with Spree

Spree’s translations for emails, API messages and validation messages are now part of Spree itself, in 43 languages besides English. Regional English (en-GB and the like) now falls back to en. A key a language does not translate yet falls back to English, unless your app configured its own config.i18n.fallbacks. See Translations.
  • Remove spree_i18n from your Gemfile. Its last release is empty and only warns. Older releases load their own, larger files over Spree’s.
  • Use I18n.t with the full key. Spree.t(:free) becomes I18n.t('spree.free'), and Spree.t(:x, scope: :api) becomes I18n.t('spree.api.x'). Spree.t keeps working until 6.1 with a deprecation warning.
  • A missing key is plain text. Spree.t used to return a <span class="translation_missing"> HTML element for a key it could not find. It now returns what I18n.t returns.
  • Untranslated keys fall back to English. Spree’s translations are complete only in English. With config.i18n.fallbacks = true, as Rails generates for production, Spree adds English after your default locale. An app that sets its own fallback list or turns fallbacks off keeps its choice.
  • spree/testing_support/i18n does nothing. Set config.i18n.raise_on_missing_translations = true in your test environment to catch missing keys.
  • Kaminari is no longer loaded. It came in through spree_i18n. Spree paginates with Pagy, and the Spree::Base.page bridge for a custom Kaminari page_method_name is gone. Add kaminari to your Gemfile if your own code uses it.
  • Translations for removed screens are gone. Keys used only by the old admin and storefront, and by the v2 API, were removed. If your code reads one, copy it into your app’s locale files.

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 by Spree::Cart as well as Spree::Order, with the same warning.

Meilisearch moved to its own gem

Meilisearch is no longer part of spree_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:
Your 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 same read_<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:
Storefront customers are not part of this system: the old :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:
Two enforcement changes on the Admin API:
  • JWT staff pass the same per-controller key gate as secret keys. A request whose principal lacks <read|write>_<resource> gets a 403 with details.required_permission naming the missing key.
  • Staff endpoints moved out of the settings scope. /admin_users, /invitations, and /roles now require the new read_staff / write_staff scopes; secret keys minted with settings before 6.0 lose those endpoints. /countries and /locales became 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 to description and 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.
Both internal-note serializers now return the pair. Previously orders exposed only plain text and customers only HTML. Stored markup is also held to a narrower allowlist than 5.6 — see the rich-text migration for what is permitted and how to widen it.

Upgrading a storefront

A storefront built on the Store API and @spree/sdk, such as the Spree storefront, needs @spree/sdk 2.0 and the changes below. Most are type errors that point at the line to change. The quickest path, for a person or a coding agent:
  1. Upgrade @spree/sdk to 2.x and run the type checker (pnpm exec tsc --noEmit, or your framework’s build).
  2. Match each error against the table below and apply its fix. Fix values where they come from (a context, a form, an API response), not with a cast at each call site.
  3. Search for the deprecated names in the deprecated fields section and switch them, even where nothing fails to compile.
To hand the upgrade to a coding agent, give it the prompt below.

Upgrade with a coding agent

Copy this prompt into Claude Code, Cursor or another coding agent, opened in your storefront’s repository. It covers what upgrading the Spree storefront itself turned up, including the changes that compile but behave differently.
Prompt

List filters and sort are typed

Each list() method now takes the filters and sort fields its endpoint accepts. A misspelled filter, a filter the endpoint doesn’t support, or an unknown sort field used to return the unfiltered list; it is now a compile error. ProductListParams, CategoryListParams, CollectionListParams and OrderListParams keep their names but no longer accept arbitrary keys.
  • A sort value read from the URL is a plain string. Narrow it where you read it; the API still validates the value at runtime:
    src/lib/utils/product-query.ts
  • Filters your backend adds are compile errors until you declare them. The published types list only the filters Spree itself declares. Any filter or scope your backend adds (through Spree.ransack.add_attribute, add_scope or a model decorator) still works in the API, but TypeScript rejects the key. Generate the declarations from your app, and run the command again whenever a filter changes:
    It writes a declaration file into each app under apps/, for the SDK that app uses (the storefront, the dashboard and the seller panel). For a one-off filter, send it inside q instead (list({ q: { erp_id_eq: 'ERP-1' } })), which is not type checked. See using your own filters from TypeScript for both, and for declaring filters by hand.
  • Amount filters take decimal strings. price_gte, price_lte and the other money filters are typed as strings, like every money value in 6.0. Send '20.00', not 20.
  • Prefer search. Products, categories and collections take q[search] (the SDK’s search key), which matches more than name_cont. See which filters an endpoint accepts.

Completing a cart can return an order group

carts.complete() returns Order | OrderGroup. In a marketplace, a cart holding several sellers’ goods divides into one order per seller and returns the group. orders.get() does the same when given the cart’s id, which is what an order confirmation page usually holds. Narrow the result with isOrderGroup before reading order fields:
An order group carries the same totals as an order (item_total, delivery_total, discount_total, tax_total, fee_total, gift_card_total, store_credit_total, total, amount_due) and the payments made at checkout, so one order summary component can render either.

Store credit no longer includes gift cards

A gift card is redeemed as store credit. store_credit_total used to include that amount as well as gift_card_total, so a summary showing both rows took it off twice. It now holds only the store credit that is not from a gift card. A storefront that shows one row or the other keeps working, and one that shows both no longer deducts the gift card twice.

Request types match the API

Request types (CreateCartParams, UpdateCartParams, AddLineItemParams, AddressParams and the rest) are generated from what each endpoint accepts. Fields the API never accepted are gone, so a type error there means the value was being dropped. Fields the API accepted but the old types left out are now available.
  • A cart’s addresses take CartAddressParams, not AddressParams. AddressParams is the customer’s address book entry. A cart address also takes id (an address from the book) and quick_checkout (relaxed validation for a wallet’s partial address):
    src/lib/data/express-checkout-flow.ts
  • Some requirements relaxed. AddLineItemParams.quantity is optional (it defaults to 1), and RegisterParams.password_confirmation is optional.
  • Renamed types keep their old names until 6.1, marked deprecated: LineItemInput is LineItemInputParams. AddressParams and CompanyAddressParams are split by action into AddressCreateParams / AddressUpdateParams and CompanyAddressCreateParams / CompanyAddressUpdateParams: creating requires the name, street, city and country, and updating takes any of them.

Country and currency codes are typed

country_code and currency are typed as CountryCode (ISO 3166-1 alpha-2, such as 'US') and CurrencyCode (ISO 4217, such as 'EUR') in requests and responses. A plain string no longer type-checks where a request expects one. The values come from the API already typed (Market.currency, Market.country_codes, Country.iso, Currency.iso_code, Address.country_code), so type the state that holds them rather than casting each use:
src/contexts/StoreContext.tsx
A code typed by a shopper or read from the URL is still a string. Check it against the store’s own list (market.country_codes, the currencies endpoint) before using it, which also narrows its type:

Money can be null

Every money field, including display_* amounts (a gift card’s or store credit’s display_amount_remaining, a delivery rate’s cost), is typed string | null: it is null for a guest when the store hides prices (prices_hidden). Handle null where you format or add amounts, for example by showing nothing, or a “price on request” label.

Store credit amounts are decimal strings

carts.storeCredits.apply() takes the amount as a decimal string, like every other money value. Leave the amount out to apply as much store credit as the cart can use:
Passing a number still works until 6.1: the SDK marks that form @deprecated, and the endpoint (POST /carts/:id/store_credits) reads a JSON number through its text and logs a deprecation warning. From 6.1 the endpoint refuses a number with 422 invalid_money_format, so a storefront that calls the API without the SDK must send a string too.

The price filter’s range is text

The price filter that products.filters() returns gives its range as decimal strings, like every other amount: { min: '9.99', max: '120.00' }, where 5.6 sent numbers (9.99). PriceRangeFilter types them as strings, so a price slider that does arithmetic on min and max, or passes them to a slider component that expects numbers, now fails to compile (the first 2.0 release candidates still typed them as numbers). Use the SDK’s compareMoney and subtractMoney for exact comparisons, and convert to a number only where the slider component needs one, for display:
Send the chosen bounds back as strings (price_gte: '20.00'), not the slider’s numbers.

Status values changed

The statuses of fulfillments and the payment and fulfillment statuses of orders use new values. This is not a type error, because status types accept any string, so search your code for the old values. A fulfillment’s status, and an order’s fulfillment_status: An order’s fulfillment_status also keeps partial and backorder. An order’s payment_status is worked out from the payments and refunds on the order, so an old value can become more than one new value:

Deprecated fields still work until 6.1

These Store API fields keep working in 6.0 and are marked @deprecated in the SDK types, so editors strike them through. Switch now; they are removed in 6.1:

Other Store API changes to check

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, Promotion and PaymentMethod belongs_to :store; the stores: [...] writers are gone, they will work until 6.1 release with a deprecation warning
  • The Store API v3 contract is stable across the split — cart endpoints keep their shapes; requirements gains code (and renames the delivery entry’s field/code to delivery_method, see above), and serializer number on carts is bridged as described above.
This guide tracks the 6.0 development line and will grow until release. If a behavioral change you hit isn’t documented here, treat it as a documentation bug and report it.