Skip to content

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

PropertyTypeDescription
track_inventoryboolWhether stock is tracked
hide_if_out_of_stockboolHide product when out of stock
allow_negative_stockboolAllow stock to go below zero
low_stock_thresholdintLow stock warning threshold
allow_pre_orderboolAccept orders when out of stock
units_in_stockint|nullPhysical units on hand
units_reservedintUnits held by pending orders

Product Inventory Methods

The Product and ProductVariant models provide inventory methods.

getSalableQuantity

Returns the available stock (physical minus reserved).

php
$product->getSalableQuantity(?int $siteId = null): int

Returns 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.

php
$product->isOutOfStock(): bool

Returns 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.

php
$product->isLowStock(): bool

Returns 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.

php
$product->reserveStock(int $quantity): void

decreaseStock

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.

php
$product->decreaseStock(int $quantity): void

When allow_negative_stock is false, a database guard prevents stock from going below zero.

releaseStock

Releases a reservation without touching physical stock.

php
$product->releaseStock(int $quantity): void

Variant 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 CodeConstantStock Action
newOrderStatus::STATUS_NEW$order->reserveStockValues()
paidOrderStatus::STATUS_PAID$order->markAsPaymentProcessed() (no stock action)
shippedOrderStatus::STATUS_SHIPPED$order->decreaseStockValues()
cancelledOrderStatus::STATUS_CANCELLED$order->releaseStockValues()
refundedOrderStatus::STATUS_REFUNDEDNo 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:

php
$order->reserveStockValues();   // Reserve stock for all items
$order->decreaseStockValues();  // Decrement stock for all items
$order->releaseStockValues();   // Release reservations for all items

Each 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:

php
// 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:

twig
{% 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

twig
{% 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 Warehouse and InventoryStock models for per-location stock tracking
  • Overrides getSalableQuantity() on Product and ProductVariant to aggregate stock across warehouses assigned to the current site
  • Intercepts shop.order.stockChanged to perform warehouse-based reserve/decrease/release instead of modifying local columns
  • Replaces the units_in_stock field 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.