> ## Documentation Index
> Fetch the complete documentation index at: https://spreecommerce.org/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Upgrading to Spree 6.0

> Guide to upgrading a Spree 5.6 application to Spree 6.0

<Info>
  Before proceeding to upgrade, please ensure you're at [Spree 5.6](/docs/v5/developer/upgrades/5.5-to-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.
</Info>

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

<CodeGroup>
  ```bash Spree CLI (Docker) theme={"theme":"night-owl"}
  spree upgrade
  ```

  ```bash Without Spree CLI theme={"theme":"night-owl"}
  # cd backend if you're in the monorepo root
  bundle update
  bundle exec rake spree:install:migrations && bin/rails db:migrate
  bundle exec rake spree:upgrade
  ```
</CodeGroup>

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`.

```ruby theme={"theme":"night-owl"}
# Gemfile
gem 'spree_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.

### Convert incomplete orders into carts

```bash theme={"theme":"night-owl"}
bundle exec rake spree:migrate_incomplete_orders_to_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

```bash theme={"theme":"night-owl"}
bundle exec rake spree:migrate_adjustments_to_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

```bash theme={"theme":"night-owl"}
bundle exec rake spree:migrate_shipping_to_delivery
bundle exec rake spree:migrate_zones_to_delivery_zones
bundle exec rake spree:migrate_calculator_bounds_to_delivery_method_rules
```

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

```bash theme={"theme":"night-owl"}
bundle exec rake spree:remove_master_variant
```

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

```bash theme={"theme":"night-owl"}
bundle exec rake spree:migrate_taxons_to_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

```bash theme={"theme":"night-owl"}
bundle exec rake spree:migrate_rich_text_to_columns
```

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:

```ruby theme={"theme":"night-owl"}
# config/initializers/spree.rb
Spree::RichTextSanitizer.allowed_tags += %w[table thead tbody tr th td]
Spree::RichTextSanitizer.allowed_attributes += %w[colspan rowspan]
```

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):

```ruby theme={"theme":"night-owl"}
require 'action_text/engine'
require 'action_cable/engine'
```

### Backfill order coupon codes

```bash theme={"theme":"night-owl"}
bundle exec rake spree: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

```bash theme={"theme":"night-owl"}
bundle exec rake spree:markets:migrate_checkout_zones
bundle exec rake spree:backfill_order_markets
```

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

```bash theme={"theme":"night-owl"}
bundle exec rake spree:stock:recount_levels
```

`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

```bash theme={"theme":"night-owl"}
bundle exec rake spree:upgrade:backfill_actor_types
```

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

```bash theme={"theme":"night-owl"}
bundle exec rake spree:upgrade:migrate_users_to_customers
```

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.

<Warning>
  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`. 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`.
</Warning>

### Name countries and states by ISO code

```bash theme={"theme":"night-owl"}
bundle exec rake spree:upgrade:migrate_country_state_codes
```

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.

## 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](https://guides.rubyonrails.org/active_record_encryption.html) — 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:

```ruby config/application.rb theme={"theme":"night-owl"}
%i[primary_key deterministic_key key_derivation_salt].each do |key|
  value = ENV["ACTIVE_RECORD_ENCRYPTION_#{key.upcase}"].presence ||
    credentials.dig(:active_record_encryption, key).presence
  config.active_record.encryption[key] = value if value
end
```

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:

<CodeGroup>
  ```bash Spree CLI (Docker) theme={"theme":"night-owl"}
  spree encryption init           # adds a set to your project's .env (never overwrites existing keys)
  spree encryption init --print   # prints a set for your production host
  ```

  ```bash Without Spree CLI theme={"theme":"night-owl"}
  bin/rails db:encryption:init    # prints a set; put it in env vars or encrypted credentials
  ```
</CodeGroup>

Recreate the containers so they pick up the new `.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.

<Warning>
  Never change or lose the keys once data is encrypted — the encrypted records become unreadable.
</Warning>

**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](/docs/developer/deployment/environment_variables#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 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:

```ruby theme={"theme":"night-owl"}
# config/initializers/spree.rb
Spree::Checkout::Registry.add_requirement(
  step: :payment,
  field: :po_number,
  message: 'PO number is required',
  satisfied: ->(cart) { cart.metadata['po_number'].present? },
  applicable: ->(cart) { cart.customer.present? }
)
```

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 `delivery_method`, not `shipping_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. The `Spree.t('checkout_requirements.shipping_method_required')` translation key was renamed to `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.

## 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`:

| Removed | Use instead |
| - | - |
| `Spree::ReturnAuthorization` | `Spree::Return` or `Spree::Exchange` |
| `Spree::CustomerReturn` | `Spree::Return` — receiving is a status on the same record |
| `Spree::Reimbursement`, `Spree::ReimbursementType` | `Spree::Returns::Refund` (a workflow, not a model) |
| `Spree::ReturnItem` | `Spree::ReturnLineItem` / `Spree::ExchangeLineItem` / `Spree::ClaimLineItem` |
| `Spree::ReturnItem::EligibilityValidator::*` | the `validate` hook — see below |
| `Spree::ReturnAuthorizationReason` | `Spree::ReturnReason` (constant alias kept until 6.1, with a warning) |

<Note>
  **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.
</Note>

### 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`:

```ruby theme={"theme":"night-owl"}
Spree::Return.add_status('inspecting', after: 'received')
```

### 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:

| Old validator | Where the rule lives now |
| - | - |
| `TimeSincePurchase` | `Spree::Returns::EligibilityValidator`, registered on the `validate` hook |
| `OrderCompleted` | inline guard in `Returns::Create` |
| `InventoryShipped` | structural — only fulfillment items can be returned, and quantities are checked against what shipped |
| `NoReimbursements` | quantity math — prior return line items are subtracted, so units can't come back twice |
| `RMARequired` | gone: the `Return` **is** the request, so there is nothing to require |

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.

<Warning>
  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:

  | Deprecated setting | Use instead |
  | - | - |
  | `return_eligibility_number_of_days` | `preferred_return_window_days` on `Spree::Market`, or a `returns.create.validate` hook |
  | `restock_inventory` | `Spree::ReturnLineItem#resellable`, decided per line item at receiving |
  | `expedited_exchanges`, `expedited_exchanges_days_window` | `Spree::Exchange` and the `Exchanges::Fulfill` workflow |
</Warning>

Replace it by swapping the handler:

```ruby theme={"theme":"night-owl"}
# config/initializers/spree.rb
Spree.hooks.unregister('returns.create.validate', 'Spree::Returns::EligibilityValidator')
Spree.hooks.register('returns.create.validate', 'MyStore::ReturnPolicy')

# The same validator guards exchanges — swap it there too if your policy should cover them:
Spree.hooks.unregister('exchanges.create.validate', 'Spree::Returns::EligibilityValidator')
Spree.hooks.register('exchanges.create.validate', 'MyStore::ReturnPolicy')
```

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.

<Warning>
  `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.
</Warning>

### 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` dropped its reimbursement term** rather than replacing it — refunds already net out of `payment_total`, so that term was double-counting.
* **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.

<Warning>
  **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.
</Warning>

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.

| Legacy key (stash-only) | 6.0 key | Resolves to |
| - | - | - |
| `cart_add_item_service` | `cart_add_item_workflow` | `Spree::Carts::AddItem` |
| `cart_recalculate_service` | `cart_recalculate_workflow` | `Spree::Carts::Recalculate` |
| `carts_complete_service` | `carts_complete_workflow` | `Spree::Carts::Complete` |
| `order_cancel_service` | `order_cancel_workflow` | `Spree::Orders::Cancel` |
| `order_complete_service` | `order_complete_workflow` | `Spree::Orders::Complete` |
| `shipment_update_service`, `fulfillment_update_service` | `fulfillment_update_workflow` | `Spree::Fulfillments::Update` |

New seams with no legacy counterpart:

| Key | Resolves to | Purpose |
| - | - | - |
| `cart_recalculate_totals_workflow` | `Spree::Carts::RecalculateTotals` | the single totals seam |
| `order_recalculate_totals_workflow` | `Spree::Orders::RecalculateTotals` | order twin (post-placement re-sum) |
| `order_discount_create_service` | `Spree::Orders::Discounts::Create` | renamed from `order_add_manual_discount_service` (never released) |
| `order_update_statuses_service` | `Spree::Orders::UpdateStatuses` | the sole status writer |

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 raise `NoMethodError`. Most were one-line delegations to a replacement that already exists.

| Removed | Use instead |
| - | - |
| `Product#default_image`, `#featured_image`, `#primary_image` | `#primary_media` |
| `Variant#default_image`, `#primary_image` | `#primary_media` |
| `Address#user_default_billing?`, `#user_default_shipping?` | `#is_default_billing?`, `#is_default_shipping?` |
| `OptionType#color?` | `#color_swatch?` |
| `Category#set_store` | `#ensure_store` |
| `Category.for_taxonomy` | `.for_store` |
| `Store#admin_users` | `#users` |
| `Store#supported_shipping_zones` | `#countries_with_shipping_coverage` |
| `Spree.searcher_class` | `Spree.search_provider` |
| `Spree.admin_user_class.spree_admin_created?` | `.spree_admin.exists?` |
| `BaseMailer#set_email_locale` | wrap the action body in `with_store_locale(store) { ... }` |
| `Asset#styles`, `ImageMethods#generate_url`, `#original_url` | Active Storage variants with `cdn_image_url` |
| `Spree::ImageMethods`, `Spree::NumberAsParam` (concerns) | deleted — `NumberAsParam` was already a no-op; prefixed IDs come from `Spree::PrefixedId` |
| `Product.with_option`, `.with`, `.in_name`, `.in_name_or_keywords`, `.in_name_or_description`, `.with_ids`, `.for_user` | `.with_option_value`, `.search`, `where(id: ids)` |
| `Product.add_search_scope` | plain `scope :name, -> { ... }` |
| `Spree::Image::Configuration::ActiveStorage` | deleted — an empty no-op module; all logic lives in `Spree::Asset` |
| `Spree::OrderRouting::Strategy::Legacy` | `Spree::OrderRouting::Strategy::Rules` (see below) |
| `private_metafields` association | read `custom_fields` and filter, or query `Spree::CustomField.admin_only` |
| `multi_search` (`Product`, `Variant`, `Order`, customers; also `q[multi_search]` in the Admin API) and `Searchable.sanitize_query_for_multi_search` / `.multi_search_condition` | `search` / `q[search]`, `sanitize_query_for_search` / `search_condition`. Saved exports or links still using `multi_search` lose their filter |

### 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.

### 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::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`:

| Removed | Use instead |
| - | - |
| `variant.price`, `product.price` | `variant.price_in(currency).amount` or `amount_in(currency)` |
| `variant.price = x`, `product.price = x` | `variant.set_price(currency, x)` |
| `variant.default_price` | `variant.price_in(currency)` |
| `variant.currency`, `product.currency` | the currency is an argument now — pass the one you mean |
| `display_price`, `display_amount` | `price_in(currency).display_amount` |
| `compare_at_price=` | `set_price(currency, amount, compare_at_amount)` |
| `display_compare_at_price` | `price_in(currency).display_compare_at_amount` |
| `price_including_vat_for(opts)` | `price_in(currency).price_including_vat_for(opts)` |
| `has_default_price?` | `prices.base_prices.exists?(currency: currency)` |

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.)
* Localized number parsing still happens: `Spree::Price#amount=` runs `Spree::LocalizedNumber.parse`, so `set_price(currency, '1,599.99')` works as `price=` 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 raises `NameError` or `NoMethodError` the first time that
code runs — loudly, but not necessarily at boot:

```bash theme={"theme":"night-owl"}
grep -rn "PermittedAttributes" app config lib
grep -rnE "permitted_[[:alnum:]_]+_attributes" app config lib
```

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:

| What you were adding | Where it goes in 6.0 |
| - | - |
| A merchant-managed field (text, number, dropdown) | [Custom Fields](/docs/developer/core-concepts/metafields) — no code, and filterable/sortable |
| Config for an STI type you register (promotion rule, delivery method rule, …) | `additional_permitted_attributes` on that subclass, as before — unchanged |
| A real database column your extension added to a core model | `additional_permitted_attributes` on the model |

**3. Point each declaration at the model.** It stays in your initializer — only
the receiver changes, from the global registry to the model itself:

```ruby config/initializers/spree.rb theme={"theme":"night-owl"}
# Before
Spree::PermittedAttributes.product_attributes << :brand_id

# After
Spree::Product.additional_permitted_attributes += [:brand_id]
```

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:

```ruby theme={"theme":"night-owl"}
class BrandsController < Spree::Api::V3::Admin::ResourceController
  protected

  def model_class
    Spree::Brand
  end

  # Before: no such method — the base class inferred `brand_attributes`
  # from the model name. Now you say what you accept.
  def resource_permitted_attributes
    [:name, :slug, :description]
  end
end
```

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:

```ruby theme={"theme":"night-owl"}
patch "/api/v3/admin/products/#{product.prefixed_id}",
      params: { brand_id: brand.id }, headers: headers

expect(product.reload.brand_id).to eq(brand.id)
```

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.

<Warning>
  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.
</Warning>

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](/docs/developer/core-concepts/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](/docs/developer/core-concepts/imports-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](/docs/developer/customization/emails) 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:

| ERB view (removed) | Liquid template |
| - | - |
| `spree/shared/_base_mailer_header`, `_base_mailer_footer`, `_base_mailer_stylesheets`, `_mailer_logo`, `_purchased_items_styles` | `layouts/spree/base_mailer.liquid` |
| `spree/shared/_order_summary_section`, `_purchased_items_table`, `purchased_items_table/*` | `spree/shared/_order_summary.liquid`, `_line_item.liquid`, `_purchase_totals.liquid`, `_summary_row.liquid` |
| `spree/shared/_fulfillment_group_section`, `_purchase_totals` | `spree/shared/_fulfillment_group.liquid`, `_purchase_totals.liquid` |
| `spree/<mailer>/<email>.html.erb` and `.text.erb`, for every mailer | `spree/<mailer>/<email>.liquid`, plus an optional `<email>.text.liquid` |

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](/docs/developer/customization/emails#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 by `Spree::Cart` as well as `Spree::Order`, with the same warning.

| Deprecated | Use instead |
| - | - |
| `Order#finalize!` | `Spree.order_complete_workflow` |
| `Order#updater`, `Spree::OrderUpdater`, `Spree::CartUpdater` | `#recalculate_totals!` / `#update_statuses!` |
| `Order#shipping_discount` (Order and Cart) | `#fulfillment_discount` |
| `Order#special_instructions` (Order and Cart) | `#customer_note` (column renamed) |
| `Order#promo_total`, `#item_count`, `#ship_total`, `#shipment_total` (Order and Cart) | `#discount_total`, `#total_quantity`, `#delivery_total` (columns renamed) |
| `LineItem#promo_total`, `Fulfillment#promo_total` | `#discount_total` (columns renamed) |
| `Address#firstname`, `#lastname`, `#zipcode` | `#first_name`, `#last_name`, `#postal_code` (columns renamed) |
| `Address.normalize_zipcode`, `#normalized_zipcode` | `.normalize_postal_code`, `#normalized_postal_code` |
| `Order#bill_address_firstname`, `#bill_address_lastname` | `#bill_address_first_name`, `#bill_address_last_name` |
| `OptionType#presentation`, `OptionValue#presentation` | `#label` (column renamed, translations included). Reads and writes on an instance only — `where(presentation:)` and `find_by(presentation:)` raise, because Mobility owns `label` so the bridge cannot be an attribute alias. Query by `label`. |
| `OptionValue#option_type_presentation` | `#option_type_label` |
| `q[presentation_cont]` (option type / value filters) | `q[label_cont]` — the Ransack whitelist publishes the column, so the filter key moves with it |
| `Spree::PresentationTranslatable` | `Spree::LabelTranslatable` |
| `Spree::WishedItem`, `Wishlist#wished_items`, `#wished_items_count` | `Spree::WishlistItem`, `#wishlist_items`, `#wishlist_items_count` (class and table renamed; the `wi_` prefixed IDs are unchanged, so client-held IDs keep resolving) |
| `use_billing` / `clone_billing_address` | `use_shipping` (shipping address is canonical) |
| `Fulfillment#ship`, `#ship!` | `Spree.fulfillment_fulfill_workflow.call(fulfillment:)` (`Spree::Fulfillments::Fulfill`) |
| `Fulfillment#shipped?`, `#can_ship?`, `#shipping_method`, `#add_shipping_method` | `#fulfilled?`, `#can_fulfill?`, `#delivery_method`, `#add_delivery_method` |
| `LineItem#target_shipment` | `#target_fulfillment` |
| `Order#create_proposed_shipments` / `#create_proposed_fulfillments` (Order and Cart) | `#rebuild_fulfillments!` |
| `#user`, `#user=`, `#user_id`, `#associate_user!` (Order and Cart) | `#customer`, `#customer=`, `#customer_id`, `#associate_customer!` |
| `#shipments`, `#set_shipments_cost`, `#ensure_updated_shipments`, `#display_shipment_total` (Order and Cart) | `#fulfillments`, `#set_fulfillments_cost`, `#ensure_updated_fulfillments`, `#display_delivery_total` |
| `#update_with_updater!` (Order and Cart) | `#recalculate_totals!` |
| `#available_payment_methods`, `#collect_payment_methods`, `#collect_frontend_payment_methods` (Order and Cart) | `#payment_methods` |
| `#all_line_items` (Order and Cart) | `#line_items` |
| `Order#remove_out_of_stock_items!` | cart-side only (`Spree::Carts::RemoveOutOfStockItems`) |
| `Order#delivery_required?` (Order and Cart) | `#delivery_step_required?` — digital and pickup are deliveries too; this asks whether the customer must choose a delivery option |
| `Order#requires_ship_address?` (Order and Cart) | `#shipping_address_required?` — decided by the selected delivery methods (`DeliveryMethod#requires_address?`); digital, pickup, and pickup-point deliveries need no customer address |
| `requirements[].field` / `.code` `shipping_method` (Store API) | `delivery_method` / `delivery_method_required` — no bridge; clients keying off the delivery requirement must switch both tokens |
| `Cart#number` (Store API field) | `id` — carts have no order-style number; the field mirrors the prefixed ID for one release |
| `order.completed` event | `order.placed` |
| Calling cart services with `order:` kwargs | `cart:` kwargs |
| `OrderWalkthrough` (testing support) | factories: `:cart_ready_for_delivery`, `:cart_ready_to_complete`, `:completed_order_with_totals` |
| `Spree::Metafield`, `Spree::MetafieldDefinition`, `Spree::Metafields::*` | `Spree::CustomField`, `Spree::CustomFieldDefinition`, `Spree::CustomFields::*` |
| `include Spree::Metafields` | `include Spree::HasCustomFields` |
| `#set_metafield`, `#get_metafield`, `#has_metafield?` | `#set_custom_field`, `#get_custom_field`, `#has_custom_field?` |
| `metafields` / `public_metafields` associations | `custom_fields` / `storefront_custom_fields` |
| `.with_metafield_key`, `.with_metafield_key_value` | `.with_custom_field_key`, `.with_custom_field_key_value` |
| `Spree.metafields` | `Spree.custom_fields` |
| `CustomFieldDefinition#name`, `#metafield_type`, `#display_on` | `#label`, `#field_type`, `#storefront_visible` (columns renamed) |
| `Spree::SearchProvider::Meilisearch` | `SpreeMeilisearch::SearchProvider` (moved to the `spree_meilisearch` gem) |
| `Spree::SearchProvider::ProductPresenter` | `SpreeMeilisearch::ProductPresenter` (moved to the `spree_meilisearch` gem) |
| `Spree::Digital` | `Spree::DigitalAsset` (table `spree_digitals` → `spree_digital_assets`, `digital_id` → `digital_asset_id`) |
| `Variant#digitals`, `Product#digitals`, `DigitalLink#digital` | `#digital_assets` / `#digital_asset` |
| `digital.created` / `.updated` / `.deleted` events | `digital_asset.*` (both emitted for one release) |
| `PermittedAttributes.digital_attributes` | `.digital_asset_attributes` |
| `GET /api/v3/store/digitals/:token` | `GET /api/v3/store/digital_links/:token` (old path keeps working) |

## 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:

```ruby theme={"theme":"night-owl"}
# Gemfile
gem 'spree_meilisearch'

# config/initializers/spree.rb
Spree.search_provider = 'SpreeMeilisearch::SearchProvider'
```

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.

| Removed | Use instead |
| - | - |
| `Spree::PermissionSets::*` (all classes) | catalog keys on the role row: `Spree::Role#permissions` |
| `Spree.permissions.assign(:role, [sets])` | the role editor, `POST /api/v3/admin/roles`, or plain ActiveRecord in seeds |
| Custom permission sets with record-level rules | Replace the ability class: `Spree::Dependencies.ability_class = 'MyApp::Ability'` |
| `Spree::ApiKey::SCOPES` | `Spree::ApiKey.known_scopes` (derived from the catalog) |

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:

```ruby theme={"theme":"night-owl"}
# db/seeds.rb — "roles as code" is plain ActiveRecord now
Spree::Store.default.roles.find_or_create_by!(name: 'support')
  .update!(permissions: %w[read_orders read_customers])
```

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:

```ruby theme={"theme":"night-owl"}
Spree.permissions.register_scope(:reviews, group: :catalog, resources: -> { [SpreeReviews::Review] })
```

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:

| Resource | Plain text | HTML |
| - | - | - |
| Product, Category, Collection | `description` | `description_html` |
| Order, Customer | `internal_note` | `internal_note_html` |

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](#move-rich-text-out-of-action-text) for what is permitted and how to widen 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`, `Promotion` and `PaymentMethod` `belongs_to :store`; the `stores: [...]` writers are gone. Multi-store sharing lives in the `spree_multi_store` extension.
* 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.

<Warning>
  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.
</Warning>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.