Inventory
Sources and stocks, salable quantity against quantity, reservations, and why the legacy stock API no longer tells the truth.
Magento\InventorySalesApi\Api\GetProductSalableQtyInterface::execute(string $sku, int $stockId): floatMulti-Source Inventory, usually written MSI, is how Magento has tracked stock since 2.3. It ships with Open Source; there is nothing to buy or install.
Sources and stocks
A source is a physical place that holds goods: a warehouse, a shop, a supplier. Quantity is stored per source in inventory_source_item, keyed by SKU and source code. A stock is a sellable pool: it aggregates one or more sources and is attached to one or more websites through a sales channel. A single-warehouse shop uses the Default Source and Default Stock and never has to think about either.
Quantity against salable quantity
Quantity is what sits on the shelf. Salable quantity is what you may still sell: the stock's source quantities, minus open reservations, minus the Out-of-Stock Threshold. Reservations are append-only rows in inventory_reservation, written when an order is placed and compensated when it ships or is cancelled. Nothing decrements a quantity column during checkout, which is what lets two shoppers check out at the same moment without fighting over a row.
So the admin grid's Salable Quantity will not equal inventory_source_item.quantity, and a report querying that column disagrees with the storefront. This is correct behaviour, not drift.
The old API
Magento\CatalogInventory\Api\StockRegistryInterface has been deprecated since 2.3.0 (module version 100.3.0), because single-source data cannot express a salable quantity. Use GetProductSalableQtyInterface for one SKU and AreProductsSalableInterface for many.
After changing quantities run bin/magento indexer:reindex inventory and bin/magento cache:flush.
Example
<?php
// app/code/Vendor/Inventory/Model/SalableQty.php
declare(strict_types=1);
namespace Vendor\Inventory\Model;
use Magento\Framework\Exception\LocalizedException;
use Magento\InventorySalesApi\Api\GetProductSalableQtyInterface;
use Magento\InventorySalesApi\Model\StockByWebsiteIdResolverInterface;
use Magento\Store\Model\StoreManagerInterface;
class SalableQty
{
public function __construct(
private readonly GetProductSalableQtyInterface $getProductSalableQty,
private readonly StockByWebsiteIdResolverInterface $stockByWebsiteIdResolver,
private readonly StoreManagerInterface $storeManager
) {
}
public function forCurrentWebsite(string $sku): float
{
$websiteId = (int)$this->storeManager->getStore()->getWebsiteId();
$stock = $this->stockByWebsiteIdResolver->execute($websiteId);
try {
return $this->getProductSalableQty->execute($sku, (int)$stock->getStockId());
} catch (LocalizedException $e) {
// Thrown for a SKU with no source items, or a composite product
// whose salable quantity is derived from its children.
return 0.0;
}
}
}
// Called from a view model or an API resolver. The stock id is never hard-coded
// to 1: on a multi-website install each website resolves to its own stock.
// Changing the class requires bin/magento setup:di:compile in production mode.When to use it
- Explain why the admin's Salable Quantity differs from the quantity in the warehouse export.
- Write a nightly stock feed that updates quantities for one source without touching the others.
- Show a live salable quantity on the product page for the current website's stock.
- Replace a module that still calls the deprecated StockRegistryInterface.
More examples
Setting a quantity on one source
This is the shape of a warehouse stock feed. SourceItemsSaveInterface::execute() takes an array on purpose, so a nightly file of ten thousand lines becomes a handful of calls rather than ten thousand saves. Status is separate from quantity: a source item with quantity 0 and STATUS_IN_STOCK is a legitimate backorder setup, so set both deliberately. Follow the import with bin/magento indexer:reindex inventory, or let cron do it if the indexer is on Update by Schedule.
<?php
// app/code/Vendor/Inventory/Model/SourceQtyWriter.php
declare(strict_types=1);
namespace Vendor\Inventory\Model;
use Magento\InventoryApi\Api\Data\SourceItemInterface;
use Magento\InventoryApi\Api\Data\SourceItemInterfaceFactory;
use Magento\InventoryApi\Api\SourceItemsSaveInterface;
class SourceQtyWriter
{
public function __construct(
private readonly SourceItemInterfaceFactory $sourceItemFactory,
private readonly SourceItemsSaveInterface $sourceItemsSave
) {
}
/**
* @param array<string, float> $quantities sku => quantity
*/
public function write(string $sourceCode, array $quantities): void
{
$sourceItems = [];
foreach ($quantities as $sku => $quantity) {
$sourceItem = $this->sourceItemFactory->create();
$sourceItem->setSku($sku);
$sourceItem->setSourceCode($sourceCode);
$sourceItem->setQuantity($quantity);
$sourceItem->setStatus(
$quantity > 0
? SourceItemInterface::STATUS_IN_STOCK
: SourceItemInterface::STATUS_OUT_OF_STOCK
);
$sourceItems[] = $sourceItem;
}
// One call for the whole batch: this is the performant API.
$this->sourceItemsSave->execute($sourceItems);
}
}Appending a reservation
Reach for this only when you are building a flow Magento does not already cover, such as holding stock for a quote that is not yet an order. Every negative reservation needs a positive one later, written by whatever ends the flow, or the salable quantity shrinks forever. The metadata string is free-form and is there so you can trace a row back to the thing that created it; Magento's own order flow stores a serialised event type and object id.
<?php
// app/code/Vendor/Inventory/Model/ReserveStock.php
declare(strict_types=1);
namespace Vendor\Inventory\Model;
use Magento\InventoryReservationsApi\Model\AppendReservationsInterface;
use Magento\InventoryReservationsApi\Model\ReservationBuilderInterface;
class ReserveStock
{
public function __construct(
private readonly ReservationBuilderInterface $reservationBuilder,
private readonly AppendReservationsInterface $appendReservations
) {
}
/**
* A negative quantity holds stock back. A positive one releases it again.
*/
public function hold(string $sku, int $stockId, float $quantity, string $metadata): void
{
$reservation = $this->reservationBuilder
->setSku($sku)
->setStockId($stockId)
->setQuantity($quantity)
->setMetadata($metadata)
->build();
$this->appendReservations->execute([$reservation]);
}
}Checking many SKUs in one call
Use this on any listing, feed or bulk check. IsProductSalableInterface still exists but is deprecated in favour of this bulk form, because calling the single-SKU version inside a loop issues one set of queries per product. Each result is an IsProductSalableResultInterface carrying getSku(), getStockId() and isSalable(), so you can match results back to inputs without relying on array order.
<?php
// app/code/Vendor/Inventory/Model/SalableSkus.php
declare(strict_types=1);
namespace Vendor\Inventory\Model;
use Magento\InventorySalesApi\Api\AreProductsSalableInterface;
class SalableSkus
{
public function __construct(
private readonly AreProductsSalableInterface $areProductsSalable
) {
}
/**
* @param string[] $skus
* @return string[] the SKUs that can still be sold
*/
public function filter(array $skus, int $stockId): array
{
$salable = [];
foreach ($this->areProductsSalable->execute($skus, $stockId) as $result) {
if ($result->isSalable()) {
$salable[] = $result->getSku();
}
}
return $salable;
}
}
Discussion