docs/developer/upgrades/5.4-to-5.5.mdx
The upgrade is usually completed in five steps:
For applications created via create-spree-app command we greatly recommend using the Spree CLI to perform the upgrade. It provides a guided experience with prompts and handles the first three steps for you. If you prefer to run the commands manually or not using docker for local development, you can follow the "Without Spree CLI" path.
spree upgrade
# 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
The Spree CLI path runs all three commands for you with prompts. Recommended for local development. If you don't have the CLI yet, either install it globally or run it through npx:
# install once and use `spree …` everywhere
npm install -g @spree/cli
# or invoke without installing (each command runs through npx)
npx @spree/cli upgrade
The Without Spree CLI path is the bare equivalent. Use this on production: bundle update and db:migrate are part of your existing deploy pipeline (Heroku release phase, K8s init container, Capistrano hook, Render auto-migrate). Once the 5.5 release is up, run bundle exec rake spree:upgrade from a one-off dyno / job container / kubectl exec to perform the data backfills.
Skipping versions and re-running are both safe — bundle exec rake spree:upgrade figures out what still needs to happen and does nothing on data that's already migrated.
This is reference material — what bundle exec rake spree:upgrade (and equivalently spree upgrade) actually executes on your data. Skip if you trust the tool; read on if something failed or you're curious.
In 5.5 the product is the default owner of media. Existing variant-pinned images keep rendering, but new admin uploads attach to the product. To consolidate both into a single gallery, the upgrade runs:
<CodeGroup>spree rake spree:media:migrate_master_images_to_product_media
bundle exec rake spree:media:migrate_master_images_to_product_media
The task enqueues one Spree::Media::MigrateProductAssetsJob per product onto the images queue — make sure your job runner is processing that queue. Each job is idempotent, so re-running the task is safe; it skips products that no longer have variant-pinned assets.
For larger catalogs, tune the batching with BATCH_SIZE:
spree rake spree:media:migrate_master_images_to_product_media BATCH_SIZE=1000
bundle exec rake spree:media:migrate_master_images_to_product_media BATCH_SIZE=1000
Spree 5.5 introduces Sales Channels — a per-store distribution surface (online storefront, POS, marketplace integration, wholesale portal). Products are published to a channel via the new spree_product_publications join table, and orders are attributed to a channel via spree_orders.channel_id.
The migrations add a default boolean on spree_channels, a store_id column on spree_products, and create the new spree_product_publications table — but they do not seed default channels, attach existing products to a store, or backfill order channels. That work is done by an idempotent rake task:
spree rake spree:channels:upgrade
bundle exec rake spree:channels:upgrade
The task runs four sub-tasks in order:
spree:channels:create_defaults — creates the default "Online Store" channel for every existing store (via Store#ensure_default_channel).spree:upgrade:populate_publications — for every product that doesn't yet have a store_id, picks a "home" store from the legacy spree_products_stores join (preferring the store flagged default: true, otherwise the earliest row), sets spree_products.store_id, and creates a spree_product_publications row on each attached store's default channel. Runs in a transaction per product so a partial failure leaves nothing half-applied.spree:channels:backfill_order_channel_ids — sets spree_orders.channel_id from the legacy spree_orders.channel string column. Unknown codes auto-create a new channel under that store. NULL/blank values map to the default channel.spree:channels:backfill_product_publication_dates — copies the deprecated Product.available_on and Product.discontinue_on columns into each publication's published_at / unpublished_at (only where the publication's date is currently NULL).The task is fully idempotent — safe to re-run if it fails partway, and a no-op on stores/products/orders that have already been upgraded.
<Warning> Until `spree:channels:upgrade` runs, every product has `store_id IS NULL` and is invisible to `Product.for_store(store)`. The admin product list, storefront catalog, and search indexer all return empty. Run the task immediately after `db:migrate`. </Warning>The last step rebuilds the search index against the configured search provider:
<CodeGroup>spree rake spree:search:reindex
bundle exec rake spree:search:reindex
This is a no-op on the default Database provider (there is no external index to maintain). For Meilisearch — or any other external search provider — it is required, and it must run after the Channels upgrade: products only become visible to Product.for_store once they have a store_id, so reindexing before the channels step would index zero products. The manifest orders the steps accordingly.
If you have products attached to multiple stores via the legacy spree_products_stores join, the populate_publications task picks one "home" store per product and creates publications on every store's default channel. The spree_products_stores table is kept as legacy compat surface — the upcoming spree_multi_store extension restores the full Product has_many :stores association on top of it.
For single-store deployments this is invisible; you can move on without touching spree_products_stores again.
product_publications: [{ channel_id }] on create or use POST /api/v3/admin/channels/:id/add_products afterwards.Product.available_on= and Product.discontinue_on= setters emit deprecation warnings and now write to every per-channel publication's published_at / unpublished_at. Reading these attributes on a product prefers the current-channel publication's value over the legacy column.Spree::Channel#add_products(product_ids) is idempotent and preserves existing publication windows when called without published_at/unpublished_at kwargs.Orders modified after the upgrade auto-set channel_id via the model's before_validation :ensure_channel_presence callback, so backfill_order_channel_ids is only strictly required for orders that aren't touched again post-upgrade — but running it at upgrade time avoids surprises later. The legacy channel string column is kept on spree_orders and ignored by ActiveRecord (Spree::Order declares it in ignored_columns). It will be dropped in a later Spree release once everyone has had a chance to run the backfill.
Spree 5.5 ships alongside @spree/sdk 1.1. The backend upgrade never touches your frontend source — bump the SDK in every JavaScript consumer of the Store API and take it through your normal PR/CI cycle:
# create-spree-app projects: the Next.js storefront
cd apps/storefront
npm install @spree/sdk@^1.1
If you maintain a separate storefront repo or other integrations, repeat there. The spree upgrade command detects the conventional apps/storefront and reminds you with the currently-declared version in its "Next steps" panel.
| Spree backend | @spree/sdk |
|---|---|
| 5.4 | 1.0.x |
| 5.5 | 1.1+ |
The SDK bump pairs with one storefront code change in this release: the payment method type shorthand — update your gateway map when you bump.
bundle exec rake spree:upgrade doesn't touch your job-runner config — every app uses a different scheduler, so this one is on you.
Spree 5.5 introduces time-limited stock reservations during checkout to prevent two customers from buying the same last unit at the same time. Abandoned checkouts leave behind expired reservation rows, and Spree does not auto-schedule the cleanup — your application's job runner must run Spree::StockReservations::ExpireJob periodically (every minute is the recommended cadence).
If you skip this step, expired reservations accumulate in the table indefinitely. The Quantifier still ignores them at availability-check time (so customers see correct stock), but the table grows unbounded.
# config/sidekiq_cron.yml
expire_stock_reservations:
cron: "* * * * *"
class: "Spree::StockReservations::ExpireJob"
queue: default
# config/recurring.yml
expire_stock_reservations:
schedule: every minute
class: Spree::StockReservations::ExpireJob
# config/initializers/good_job.rb
Rails.application.configure do
config.good_job.cron = {
expire_stock_reservations: {
cron: '* * * * *',
class: 'Spree::StockReservations::ExpireJob'
}
}
end
Defaults are sensible for most stores. Reach for these only if you have a specific reason.
The default reservation TTL is 10 minutes. To override globally:
# config/initializers/spree.rb
Spree::Config[:default_stock_reservation_ttl_minutes] = 15
To override per Store, set the preference on the Store record:
store.update!(preferred_stock_reservation_ttl_minutes: 20)
The per-Store value, when set, takes precedence over the global default.
Stock reservations are enabled by default. To opt out and revert to pre-5.5 behavior (no holds during checkout, Quantifier returns raw count_on_hand):
# config/initializers/spree.rb
Spree::Config[:stock_reservations_enabled] = false
The Quantifier short-circuits before any reservation query when this is false, so there's no runtime cost and no table growth.
Spree 5.5 introduces Order Routing — a configurable, per-channel pipeline that decides which stock locations fulfill an order. Every store and every channel ships with three default rules (Preferred Location → Minimize Splits → Default Location) that produce sensible behavior out of the box, with no migration work required.
If you've heavily customized fulfillment in Spree 5.4 and aren't ready to adopt the new rules engine, you can keep the legacy pre-5.5 routing by switching the store's strategy to Spree::OrderRouting::Strategy::Legacy:
store.update!(preferred_order_routing_strategy: 'Spree::OrderRouting::Strategy::Legacy')
The Legacy strategy delegates to Spree::Stock::Coordinator, which is the exact pre-5.5 packing pipeline — every active stock location is packed, the Prioritizer distributes inventory units across the resulting packages, and no merchant routing rules are consulted. Your existing customizations on Coordinator, Packer, Prioritizer, and the splitters keep working unchanged.
These don't require any rake task — but storefronts, integrations, and merchant-facing dashboards may need code changes to handle them correctly.
When a customer is in checkout and tries to add an item, increase a quantity, or remove a line item, Spree now re-checks whether the cart still fits in available stock (subtracting what other customers are holding in their own active checkouts). If it doesn't, the change is rejected up front instead of silently completing and failing later at order submission.
Storefronts and custom integrations that act on the cart should expect this new failure path and surface the error to the customer.
Other customers now see availability reduced by all active reservations, not just by completed orders. This is the intended fix to overselling — but if you have a real-time inventory dashboard that reads count_on_hand directly (rather than going through Spree's availability checks), you'll want to expose a "Reserved" axis to merchants so they can see in-checkout demand.
type on the wire is now a shorthand, not a Rails class nameThe type attribute on Spree::PaymentMethod (returned by both the Store API and the Admin API) used to be the full Rails STI class name — "SpreeStripe::Gateway", "SpreeAdyen::Gateway", "Spree::PaymentMethod::Check". In 5.5 it switches to a stable shorthand derived from Spree::Base.api_type:
| Class | Pre-5.5 type | 5.5+ type |
|---|---|---|
Spree::PaymentMethod::Check | Spree::PaymentMethod::Check | check |
Spree::PaymentMethod::StoreCredit | Spree::PaymentMethod::StoreCredit | store_credit |
SpreeStripe::Gateway | SpreeStripe::Gateway | stripe |
SpreeAdyen::Gateway | SpreeAdyen::Gateway | adyen |
SpreePaypalCheckout::Gateway | SpreePaypalCheckout::Gateway | paypal_checkout |
SpreeRazorpayCheckout::Gateway | SpreeRazorpayCheckout::Gateway | razorpay_checkout |
The shorthand is also what POST /api/v3/admin/payment_methods now expects as type when creating a new method, and what GET /api/v3/admin/payment_methods/types returns in its type field.
This is a breaking change for any storefront or integration that string-matches the payment method type to pick which payment-gateway SDK to load. The official Spree Next.js storefront resolves the gateway in src/lib/utils/payment-gateway.ts; update its map to key on the new shorthand:
const GATEWAY_TYPE_MAP: Record<string, GatewayId> = {
// 5.5+ shorthands
stripe: "stripe",
adyen: "adyen",
paypal_checkout: "paypal",
razorpay_checkout: "razorpay",
// Pre-5.5 Rails class names — keep while you have older backends in
// the field; drop once everyone is on 5.5+.
"SpreeStripe::Gateway": "stripe",
"SpreeAdyen::Gateway": "adyen",
"SpreePaypalCheckout::Gateway": "paypal",
"SpreeRazorpayCheckout::Gateway": "razorpay",
};
If you maintain a custom storefront, search it for the legacy class strings ("SpreeStripe::Gateway", "SpreePaypalCheckout::Gateway", etc.) and add the corresponding 5.5+ shorthand alongside each one. The symptom of missing this is checkout rendering a generic "this payment method is not yet supported" placeholder instead of the gateway's SDK form.
The default routing strategy (Spree::OrderRouting::Strategy::Rules) packs the same set of stock locations as before, but the order in which locations are tried is now determined by the routing rules — Preferred Location → Minimize Splits → Default Location — rather than by raw database row order. The unit distribution (Prioritizer + Adjuster) is unchanged: top-ranked location's packages get first pick of on-hand inventory, the rest spills over.
For most stores this is invisible: when one location can fulfill the entire cart, that location now wins consistently (instead of depending on database iteration order). When the cart needs to split across locations, the same multi-location split happens — just with the location order driven by rules.
If you rely on the legacy "every location packed in iteration order, no rule consulted" behavior, see Opt out of rules-based Order Routing above.