Inventory
This reference documents the inventory system used by Meloncart's Shop plugin. The base system uses units_in_stock and units_reserved columns on Product and ProductVariant. For multi-warehouse support, see the Inventory Plugin section.
Product Inventory Properties
| Property | Type | Description |
|---|---|---|
track_inventory | bool | Whether stock is tracked |
hide_if_out_of_stock | bool | Hide product when out of stock |
allow_negative_stock | bool | Allow stock to go below zero |
low_stock_threshold | int | Low stock warning threshold |
allow_pre_order | bool | Accept orders when out of stock |
units_in_stock | int|null | Physical units on hand |
units_reserved | int | Units held by pending orders |
Product Inventory Methods
The Product and ProductVariant models provide inventory methods.
getSalableQuantity
Returns the available stock (physical minus reserved).
$product->getSalableQuantity(?int $siteId = null): intReturns max(0, units_in_stock - units_reserved). The $siteId parameter is accepted for API compatibility but is ignored in the base implementation.
isOutOfStock
Returns whether the product is out of stock.
$product->isOutOfStock(): boolReturns false if track_inventory is disabled, otherwise returns true when the salable quantity reaches zero.
isLowStock
Returns whether stock has reached the low stock threshold while the product is still available for purchase.
$product->isLowStock(): boolReturns false if track_inventory is disabled or no low_stock_threshold is set. Also available as the low_stock attribute in templates.
reserveStock
Atomically increments units_reserved for a pending order.
$product->reserveStock(int $quantity): voiddecreaseStock
Atomically decrements units_in_stock and releases the reservation. Fires shop.productOutOfStock when stock runs out, and sends a low stock alert to store managers when stock first reaches the low_stock_threshold or runs out.
$product->decreaseStock(int $quantity): voidWhen allow_negative_stock is false, a database guard prevents stock from going below zero.
releaseStock
Releases a reservation without touching physical stock.
$product->releaseStock(int $quantity): voidVariant Methods
ProductVariant provides the same methods: getSalableQuantity(), isOutOfStock(), isLowStock(), reserveStock(), decreaseStock(), and releaseStock(): operating on variant-level stock. The low stock threshold is always read from the parent product.
Stock Lifecycle
The inventory system uses a two-phase reservation model to prevent overselling:
Order placed (New) → units_reserved += qty (salable drops, physical unchanged)
Order shipped → units_in_stock -= qty, (physical stock leaves)
units_reserved -= qty
Order cancelled → units_reserved -= qty (salable restored, no physical change)How Status Changes Trigger Stock Actions
Stock operations are dispatched in OrderStatusLog::createRecord() based on the order status code:
| Status Code | Constant | Stock Action |
|---|---|---|
new | OrderStatus::STATUS_NEW | $order->reserveStockValues() |
paid | OrderStatus::STATUS_PAID | $order->markAsPaymentProcessed() (no stock action) |
shipped | OrderStatus::STATUS_SHIPPED | $order->decreaseStockValues() |
cancelled | OrderStatus::STATUS_CANCELLED | $order->releaseStockValues() |
refunded | OrderStatus::STATUS_REFUNDED | No action (stock already shipped) |
Each stock action fires the shop.order.stockChanged event before executing. Return false from this event to prevent the default stock behavior and handle it externally.
Order Methods
The Order model provides three stock lifecycle methods that iterate over all order items:
$order->reserveStockValues(); // Reserve stock for all items
$order->decreaseStockValues(); // Decrement stock for all items
$order->releaseStockValues(); // Release reservations for all itemsEach method loops through the order's items and calls the corresponding reserveStock(), decreaseStock(), or releaseStock() method on the item's variant (if present) or product.
Concurrency Safety
All stock operations use atomic database updates to handle concurrent requests safely:
// Example: atomic reservation
Db::table('shop_products')
->where('id', $this->id)
->update(['units_reserved' => Db::raw("units_reserved + " . (int) $quantity)]);This ensures that two simultaneous orders cannot both claim the same stock. The units_reserved counter acts as a soft lock: salable quantity (units_in_stock - units_reserved) decreases immediately when an order is placed.
Displaying Stock on the Storefront
Use the isOutOfStock() and isLowStock() methods for stock-aware templates. The low stock state only occurs while the product is still purchasable, making it suitable for urgency messaging:
{% if product.track_inventory %}
{% if product.isOutOfStock() %}
{% if product.allow_pre_order %}
<span class="badge bg-warning">
Pre-Order
</span>
{% else %}
<span class="badge bg-danger">
Out of Stock
</span>
{% endif %}
{% elseif product.low_stock %}
<span class="badge bg-warning">
Low Stock, Order Soon
</span>
{% else %}
<span class="text-success">
In Stock
</span>
{% endif %}
{% endif %}WARNING
Avoid displaying exact stock quantities to customers. The salable quantity changes in real time as other customers place orders, and showing exact numbers can create a poor experience if the count changes between page loads.
Variant Stock
{% set variant = product.resolveVariantSafe(post('product_options', {})) %}
{% if variant %}
{% if variant.isOutOfStock() %}
<div class="alert alert-warning">
This combination is currently out of stock.
</div>
{% endif %}
{% endif %}Inventory Plugin
The Meloncart Inventory plugin (meloncart/inventory) extends the shop with multi-warehouse inventory management. When installed, it:
- Adds
WarehouseandInventoryStockmodels for per-location stock tracking - Overrides
getSalableQuantity()on Product and ProductVariant to aggregate stock across warehouses assigned to the current site - Intercepts
shop.order.stockChangedto perform warehouse-based reserve/decrease/release instead of modifying local columns - Replaces the
units_in_stockfield on product forms with a warehouse stock relation widget - Adds a Warehouses controller under the Shop menu
The plugin uses the existing shop.order.stockChanged event: returning false to prevent the default local-column stock operations and handling inventory through InventoryStock static methods instead.