Skip to main content

Overview

When Order Routing picks one or more stock locations to fulfill an order, each location’s allocation is then run through a chain of splitters. Each splitter looks at the packages produced so far and decides whether to break them further along its own axis. Spree ships with three splitters out of the box: DeliveryProfile (keeps each package to a single delivery profile), Backordered and Weight. The default chain is DeliveryProfile then Backordered; Weight is opt-in. You add your own when you need a physical separation that isn’t expressed by any of the existing ones — refrigerated SKUs that can’t share a box with ambient ones, hazmat goods that need their own carrier label, gift-wrap items that ship from a separate processing room, and so on. Before starting, make sure you understand how delivery profiles split a cart and the order routing layer that runs before splitters.

Splitters vs Order Routing — quick recap

Routing picks which locations ship; splitters decide how each location’s packages are broken up. They live at different layers and never overlap: A plugin can absolutely ship both — for example, a refrigerated-goods plugin might add a RefrigeratedRouting::Rule (prefer locations with cold storage) and a RefrigeratedSplitter (separate cold items from ambient items within each location). The two extension points are independent.

Custom Stock Splitters

A splitter subclasses Spree::Stock::Splitter::Base and implements #split(packages). The method receives an array of packages produced by the previous splitter (or the initial single-package output of Packer#default_package), returns an array of packages, and must call return_next so the chain continues.

Step 1: Create the Splitter Class

app/models/spree/stock/splitter/refrigerated.rb

Key Method to Implement

Helpers Inherited from Splitter::Base

What package.contents Looks Like

Each package.contents is an array of Spree::Stock::ContentItem — wrappers around a Spree::FulfillmentItem. The attributes you’ll use most:
So the splitter’s job is “look at each package’s contents, decide which contents stay together, build new packages, return the chained result.”

Step 2: Register the Splitter

Splitters are registered globally on Rails.application.config.spree.stock_splitters — every order runs through the full chain. Add yours in config/initializers/spree.rb:
config/initializers/spree.rb
Spree assigns its default chain in its own after_initialize, which runs after your initializer files — so a splitter appended at the top level of the file, or in a to_prepare block, is overwritten at boot. Registering in after_initialize runs after Spree’s defaults. The chain holds the class loaded at boot, so restart the server in development after changing the splitter.

Replacing the Whole Chain

If you’d rather control the full chain (uncommon — the defaults are well-chosen), assign instead of append:

Ordering Matters

Splitters run in array order, each feeding the next. Two practical rules:
  1. Coarse before fine. DeliveryProfile runs first by default because it groups packages by delivery profile — which decides the delivery methods a package can use — before any other axis cuts in. Your custom splitter usually wants to run after it, unless you’re separating items that should never share a package even within a profile (e.g. hazmat).
  2. Backordered should usually be last among the “type” splitters. It splits on-hand from backordered items, which is a state-axis split rather than a packaging-axis split. Splitting before Backordered gives you finer category buckets; splitting after does too — pick based on whether your axis applies to backorders. Refrigerated items have the same handling whether on-hand or backordered, so running before Backordered is fine. Hazmat shipping rules might differ between on-hand (real package) and backorder (paperwork only), so running after might be cleaner.

Step 3: Test the Splitter

Splitter tests are unit tests — instantiate a Packer, hand the splitter a hand-built package, assert on the output. There’s no factory required.
spec/models/spree/stock/splitter/refrigerated_spec.rb

Step 4 (optional): Add a Variant-side Predicate

The example above relies on variant.refrigerated?. In a real plugin you’d back that with a Custom Field on Spree::Variant — say a boolean custom field with key refrigerated — and define the predicate as a thin reader:
app/models/spree/variant_decorator.rb
This keeps the splitter pure and lets merchants flag SKUs from the admin UI without touching code.

Common Pitfalls

  • Forgetting to call return_next. If you return raw packages instead of return_next(packages), every splitter after yours in the chain is silently skipped. The integration test will catch it; the unit test usually won’t.
  • Building empty packages. If you group_by and the group has no contents, build_package([]) produces a package with no contents that downstream code may treat as a real package. Skip empty groups: grouped.values.reject(&:empty?).map { |c| build_package(c) }.
  • Mutating input packages in place. A splitter should return new packages built from the contents of the inputs, not edit the originals — Packer and other splitters keep references. Use build_package(contents), not package.contents = ....
  • Doing routing-style work. A splitter only sees one location’s contents. If your logic needs to compare across locations (“ship the cheapest one first”), that’s a routing rule or strategy, not a splitter.

Coexistence with Order Routing

Splitters and routing run in series, not in parallel — every package a splitter sees comes from a single location that routing already chose. The interaction is purely top-down: Practical implications:
  • Splitter output feeds the Prioritizer. A splitter that produces 3 packages from one location adds 3 candidates to the Prioritizer’s pool. The Prioritizer keeps each unit in the highest-ranked package that still has it on hand and prunes empties.
  • Splitters can produce backordered-only packages. That’s fine — the Backordered splitter is the canonical example. The Prioritizer treats backordered packages as a fallback after exhausting on-hand options across all higher-ranked locations.
  • A splitter can’t see which location ranked higher. It runs per-location with no awareness of the rank. If you need rank-aware splitting (rare), do it in a custom strategy’s build_packages, not a splitter.

Next Steps