Overview
An order’sfulfillableLines (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.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, CancelledInProgress → Pending, Shipped, CancelledShipped → Pending, ReturnedReturned → ShippedCancelledis terminal
collection (default state Pending, fulfilled state Collected):
Pending → ReadyForCollection, Collected, CancelledReadyForCollection → Pending, Collected, CancelledCollected → Pending, ReturnedReturned → CollectedCancelledis terminal
digital (default state Pending, fulfilled state Provisioned):
Pending → Provisioned, CancelledProvisioned → PendingCancelledis terminal
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 theFulfilment 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.
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.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’screateFulfilment() 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.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:
Holding a fulfilment
A pre-ship fulfilment (pending or in-progress) can be put on hold, blocking it from shipping until released:
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.