Skip to main content
A fulfilment is a unit of “getting the order’s goods to the customer” — a shipment, a collection, or a digital provisioning — tracked independently of payment.

Overview

An order’s fulfillableLines (lines where requires_fulfilment is true) are covered by one or more Fulfilment records. When an order is placed, Lunar automatically creates its initial fulfilments: each registered fulfilment method claims the lines it covers, in priority order, and a method that claims at least one line gets one fulfilment covering them. A basket of a shippable product, a licence key, and a click-and-collect item ends up with three fulfilments — one per method. A merchant does not create the initial fulfilment by hand; they split, merge, or move lines between the fulfilments that already exist. Each fulfilment progresses through a state graph owned by its method — a shipping fulfilment ships and can be returned, a digital one is provisioned, a collection one is marked collected. The order’s own fulfilment_status is a rollup over all of an order’s fulfilments and is method-agnostic.

Fulfilment model

Fields

Relationships

Scopes

Other useful methods

Fulfilment lifecycle

state is cast to a Lunar\Core\States\Fulfilment\FulfilmentState instance. Unlike the order’s derived payment_status/fulfilment_status, this is a hand-driven, guarded state machine — an illegal transition throws.
The state graph is owned by the fulfilment’s method, not a single fixed graph. Every state and transition contributed by any registered method is known to the underlying spatie/laravel-model-states machine (so it can cast any fulfilment’s state), but a per-method guard (MethodAwareTransition) enforces that a given fulfilment only ever follows its own method’s transitions — a collection fulfilment cannot move to Shipped even though that transition exists for shipping.
Every state belongs to a fixed rollup category — Outstanding, Fulfilled, Returned, or Cancelled — which is what the order-level fulfilment_status rollup and the split/merge/return mechanics reason over, regardless of method:

Per-method transition graphs

shipping (default state Pending, fulfilled state Shipped):
  • Pending → InProgress, Shipped, Cancelled
  • InProgress → Pending, Shipped, Cancelled
  • Shipped → Pending, Returned
  • Returned → Shipped
  • Cancelled is terminal
collection (default state Pending, fulfilled state Collected):
  • Pending → ReadyForCollection, Collected, Cancelled
  • ReadyForCollection → Pending, Collected, Cancelled
  • Collected → Pending, Returned
  • Returned → Collected
  • Cancelled is terminal
digital (default state Pending, fulfilled state Provisioned):
  • Pending → Provisioned, Cancelled
  • Provisioned → Pending
  • Cancelled is terminal
Digital fulfilments have no return transition. Entering a Fulfilled-category state stamps shipped_at (unless already set); reverting from Fulfilled back to Outstanding clears it and deletes any tracking — the fulfilment was never really handed over.

Verb methods

Every fulfilment operation is a verb on the Fulfilment model, delegating to an action contract:
ship(), fulfil(), markReturned(), and transition() accept a trailing notify flag — when true (the default), a state change fires the notifications registered against it, see Notifications on fulfilment events.
split(), merge(), and moveLinesTo() only operate on fulfilments still in an Outstanding state — they reorganise quantities that have not yet been handed over, and never change the total fulfilled quantity.

Fulfilment methods

A fulfilment method is the registered driver that owns a fulfilment’s flow: its state graph, which order lines it claims, and whether it carries carrier tracking.
Core registers three, in packages/core/src/Drivers/FulfilmentMethods/: Methods claim lines in ascending priority order over a shrinking pool of unclaimed lines, so digital and collection get first pick and shipping claims whatever is left. Manage the registry via the FulfilmentMethods facade:

Creating fulfilments

Lunar creates an order’s initial fulfilments automatically when it is placed — no call is needed for the common case. To create an additional fulfilment by hand (for example, covering a subset of lines with a specific method), use the order’s createFulfilment() verb:
lines maps an order line ID to the quantity to cover. attributes['method'] defaults to shipping; location_id defaults to the store’s default location (creating one named Default if none exists yet). Before creating, the requested quantities are validated against how much of each line is still outstanding — a line’s quantity minus what its existing (non-cancelled) fulfilments already cover. Requesting more than is outstanding, or against a line where requires_fulfilment is false, throws.

Fulfilment lines

Fields

Relationships

A [fulfilment_id, order_line_id] pair is unique — a fulfilment carries at most one line row per order line.

Tracking

A fulfilment can carry several tracking references (a shipment split across boxes or carriers).

Fields

Relationships

Other useful methods

Add tracking through the fulfilment’s ship() or addTracking() verb rather than creating a FulfilmentTracking row directly, so tracking-number validation runs against the carrier:

Carriers

A carrier resolves service labels and tracking URLs for a shipping provider.
Core registers four, extending the abstract Lunar\Core\Shipping\Carriers\Carrier base (which implements URL-template substitution and pattern validation from two protected hooks): royal-mail, dpd, ups, fedex. Manage the registry via the Carriers facade:
A custom carrier extends the base class:

Holding a fulfilment

A pre-ship fulfilment (pending or in-progress) can be put on hold, blocking it from shipping until released:
Holding is orthogonal to the state graph — the fulfilment keeps its current state while held_at is set. Reasons come from the HoldReasons manifest, mirroring order cancel reasons:

Notifications on fulfilment events

ship(), fulfil(), markReturned(), and transition() fire a FulfilmentStatusUpdated event when the state actually changes, which sends any notifications registered against the new state’s key — unless the verb’s notify argument is false, or the transition was cancel() (fulfilment cancellation never notifies the customer; that is the order-level cancel() notification’s job). See the Notifications reference for how to register a notification against a fulfilment state.