Overview
Stock is modeled across four pieces: aLocation (a warehouse or store), a StockLevel (a variant’s balance at one location), an append-only StockMovement ledger behind the physical count, and a StockReservation for stock held during checkout. A denormalized rollup cached on Lunar\Core\Models\ProductVariant answers “how many can I sell” with a single indexed read, without summing across locations on every request.
The stock quantities
committed and reserved are tracked globally first because neither has a location at the moment it is created: a commitment exists once an order is placed, before any fulfilment picks a location; a reservation exists mid-checkout, before an order exists at all. The sellable figure a storefront should check is always the variant’s global rollup, not a single location’s available.
Locations
A location is a physical place — a warehouse or store — that fulfilments are assigned to and inventory is tracked against.Fields
Relationships
Scopes
Stock levels
A stock level is a variant’s stock balance at a single location.Fields
A
(product_variant_id, location_id) pair is unique — a variant has at most one level per location. There is no reserved column: reservations are global-only, since a cart never picks a location.
Relationships
Accessors
available is computed, not stored. It is the allocatable-physical figure at this location; the sellable figure for a variant is always the global rollup, described below.
Stock movements
A stock movement is an immutable, append-only entry in a variant’son_hand ledger — the only bucket that is ledgered, since committed, reserved, and unavailable are maintained counters reconstructable from order lines, reservation rows, and hold actions.
Fields
Relationships
StockMovementType
Reversals (un-ship, undo-return) are recorded as new signed movements of the same type rather than deletions, so the ledger stays append-only.
Stock reservations
A stock reservation is a time-boxable hold a checkout places against a variant before an order exists, so two concurrent checkouts cannot both claim the last unit.Fields
Relationships
Scopes
Accessors
Releasing and committing
Lunar\Core\Contracts\Actions\Products\ReleasesReservation and Lunar\Core\Contracts\Actions\Products\CommitsReservation.
The rollup on ProductVariant
Lunar\Core\Models\ProductVariant caches a global rollup, maintained whenever a variant’s stock levels, global commitment, or active reservations change:
stock_committed and stock_reserved are not pure sums of location rows: stock_committed includes commitments not yet allocated to any location, and stock_reserved has no location at all. stock_available may go negative — an oversell is allowed by design and surfaced as a warning, mirroring the fact that a placed order cannot be un-sold.
Lunar\Core\Models\Concerns\HasStock trait, ProductVariant’s default implementation of the Lunar\Core\Contracts\TracksStock capability (below).
Recording a movement
Every change toon_hand goes through one action, so the ledger can never be bypassed. It locks the (variant, location) stock level (creating it at zero if absent), appends the movement, updates on_hand, and refreshes the variant rollup, all in one transaction.
adjustStock() delegates to Lunar\Core\Contracts\Actions\Products\RecordsStockMovement. A manual admin adjustment goes through Lunar\Core\Contracts\Actions\Products\AdjustsStock instead, which records an Adjustment movement and defaults to the default location when none is given.
Reserving stock
reserveStock() delegates to Lunar\Core\Contracts\Actions\Products\ReservesStock and returns a Lunar\Core\Models\StockReservation. Committing a reservation frees the reserved quantity without writing stock_committed directly — that figure is always derived from the order book, described next.
Committed stock and the order lifecycle
Commitment is not tracked by incrementing and decrementing a counter on each event. Instead,Lunar\Core\Contracts\Actions\Products\SyncsStockCommitment recomputes a variant’s global stock_committed and each location’s StockLevel.committed from the order book directly: it gathers every order line for the variant that requires fulfilment on a placed, non-cancelled order, subtracts the quantity already fulfilled or returned, and allocates the outstanding remainder to whichever location holds the outstanding fulfilment (a location’s committed is the allocated subset; the global figure also includes commitments not yet allocated to any fulfilment). This is the single canonical predicate, shared by the lifecycle hooks that fire on order placement, fulfilment creation, shipping, returns, and cancellation, and by the reconcile command below — so live updates and a full rebuild can never disagree.
Lunar\Core\Contracts\TracksStock::syncStockCommitment() implementation, called after order-lifecycle events that change what a variant has committed.
Sellability
getTotalInventory() reads stock_available, adding backorder when the variant’s Lunar\Core\Enums\SellingPolicy is InStockOrOnBackorder:
canBeFulfilledAtQuantity() returns true when the policy is Always, or when the requested quantity does not exceed getTotalInventory().
The add-to-cart stock check is advisory: with no reservation taken at add-to-cart, stock can still sell out before an order is placed.
reserveStock() is the supported way for a checkout to hold stock, optionally time-boxed, and close that window for flows that opt in.Custom purchasables: the TracksStock capability
Anything sold implements Lunar\Core\Contracts\Purchasable, but not everything sold tracks stock — a gift card or a service can answer canBeFulfilledAtQuantity() without a StockLevel behind it. Stock participation is the separate, opt-in Lunar\Core\Contracts\TracksStock capability:
instanceof TracksStock, skipping any purchasable that does not track stock. ProductVariant is the only built-in implementation (via Lunar\Core\Models\Concerns\HasStock); a custom stock-tracked purchasable (event seats, a bundle, an external warehouse system) implements TracksStock with its own storage, since its stock rarely looks like an integer on_hand at a location.
Reconciling and expiring reservations
Two Artisan commands keep the ledger-derived figures and the reservation-derived figures consistent with their sources:on_hand as the running sum of its movement ledger, recomputes stock_reserved from active reservations, and recomputes stock_committed from the order book via the same canonical predicate the live hooks use. Pass --variant one or more times to limit the run to specific variant IDs.
expires_at has passed and that has not already been released or committed, returning its quantity to availability. Lunar schedules this command to run every minute by default.
What inventory does not cover yet
Location-scoped storefront availability (selling only what a specific location holds) and system-driven allocation routing (assigning a placed order’s commitment to a specific location, or splitting it across several) are not implemented. Availability today is always the global sum across every location. A fulfilment allocates to whichever location it is created at; nothing auto-assigns that location. Purchase-order automation forincoming is likewise out of scope for core — it is a plain field intended for a purchasing add-on to populate.