All posts
Forward Development

Why Magento 2 shows products as sold out when stock says otherwise

When a Magento 2.4 product page says Sold but the stock check says salable, two layers disagree. How to find which one, from indexers to templates.

  • Magento 2
  • Adobe Commerce
  • Operations

A product page shows a "Sold" label. The add to cart button next to it still renders, and the button works. Orders go through. The catalog team says the product is in stock, the storefront says it isn't, and someone has already run a full reindex and flushed every cache without fixing it.

A recent Magento Stack Exchange question describes this on Magento 2.4.9 with a Hyvä frontend. The reporter checked a lot before asking. inventory_source_item had qty 1 and status 1. cataloginventory_stock_item and cataloginventory_stock_status both said in stock. Reservations summed to zero and were then deleted. inventory:reservation:list-inconsistencies didn't list the SKU. IsProductSalableInterface::execute() returned true and GetProductSalableQtyInterface::execute() returned 1.0. They ran a full reindex, cache:flush, emptied the Redis page cache database, cleared var/view_preprocessed, redeployed static content and bypassed Cloudflare. Nothing changed.

That is a good diagnostic run, and it rules out the usual suspects. When every stock source agrees and the page still disagrees, the page isn't reading stock at all. It's reading something else.

Magento decides availability in more places than you think

In 2.4 with MSI, "can this be bought" gets answered in several places, and they don't have to agree:

  • Source items. inventory_source_item holds physical quantity and status per source.
  • The stock index. The inventory indexer aggregates source items into per-stock data. It only counts sources linked to that stock (inventory_source_stock_link), and a stock only applies to websites assigned to it as sales channels.
  • Reservations. inventory_reservation rows get subtracted from indexed quantity to produce salable quantity. This is the number GetProductSalableQtyInterface returns.
  • Legacy stock tables. cataloginventory_stock_item and cataloginventory_stock_status still exist, and plenty of core and third-party code still reads is_in_stock from them.
  • The product model. $product->isAvailable() and isSalable() depend on the product type and on data attached when the product or collection loaded. For configurables and bundles that means the children.
  • Cache. Full page cache, block cache and any CDN in front can all serve an answer that was correct when the page was rendered.
  • The template. Whatever the theme actually runs to decide which label to print.

The bug is almost always one layer disagreeing with the others. Reindexing only fixes the second layer, which is why "reindex and hope" works sometimes and fails the rest of the time.

Check the data layers in order

Start at the bottom and stop as soon as two layers disagree.

  1. bin/magento indexer:status. Anything invalid or stuck on schedule with a growing backlog matters more than any single SKU.
  2. Check source assignments. Is the product assigned to a source that's linked to the stock serving this website? A product can have quantity on a source the storefront's stock doesn't include.
  3. Compare salable quantity against the legacy status. If GetProductSalableQtyInterface says 1 and cataloginventory_stock_status says 0 (or the other way round), you've found the split. Next, find out which of the two the template reads.
  4. Check reservations with inventory:reservation:list-inconsistencies. Fix drift with inventory:reservation:create-compensations, not by deleting rows by hand. Deleting rows removes the evidence and can hide the real inconsistency.
  5. Check thresholds and config: the out-of-stock threshold, and whether out-of-stock products are displayed at all. A threshold can make a product unsalable while it still has positive quantity.

If everything agrees here, as it did in the Stack Exchange case, stop looking at inventory.

Rule out cache properly

A real cache check compares a cached render with an uncached one for the same URL, customer group and currency. Fetch the page with curl directly against the origin, then with FPC disabled (bin/magento cache:disable full_page), then with block_html disabled too. If the label changes between these, you have a cache key problem: the cached block doesn't vary on something it should. If the label shows on every uncached render, cache is ruled out and the template is producing the wrong answer live.

Find the template that prints the label

The word on the page tells you where to look. grep -rn the theme and app/code for the literal string, and also check the theme's i18n CSV files. "Sold" might be a translation of "Out of stock", and then the template you want uses a different phrase. bin/magento dev:template-hints:enable will show you which .phtml file rendered the block.

Hyvä makes this more important, not less. Hyvä themes replace Luma templates wholesale and render through view models and Alpine components. A child theme or module override can carry its own availability logic that ignores the core view model. The block that renders the label may have nothing to do with the block that renders the add to cart button, and that's how you get both on the same page.

A price template with a global variable

The snippet in the question is a price renderer (it calls getDisplayValue() and getAdjustmentCssClasses()), and it contains this:

global $productk; if(isset($productk)) $product = $productk;

The availability check runs against whatever $productk holds when this template runs. Nothing in the snippet ties it to the product on the page. If any template that rendered earlier in the request sets $productk, whether a related-products loop, an upsell, a configurable child or a widget, the price box asks about that product. If that product is out of stock, the page says "Sold" for a product that isn't.

That also fits the reporter's tests. They called isAvailable() on the right product and got true, which was correct. The currency clue fits too. A pure stock problem wouldn't change with display currency, but anything that changes render order or which blocks run first can change what's in a global.

The fix is to stop guessing which product you have. A price box already knows its product: $block->getSaleableItem(). Use it. Remove the global, remove the ObjectManager currency lookup (inject a view model instead), and consider taking stock logic out of the price renderer altogether. Availability belongs in the product info or add-to-cart area, where the core and Hyvä view models already handle it.

Make the fix stick

Whatever layer turns out to be wrong, write down which one it was. If it was data, find the process that produced it: an ERP import writing to legacy tables but not source items, a cron that isn't running, or a source nobody linked to the stock. If it was cache, fix the cache key. If it was a template, fix the template and search the theme for the same pattern. Code like this is rarely in just one file.

For Magento 2 work, we start by asking which layer is being read before changing anything. If you have a storefront whose stock labels and checkout disagree and the obvious steps haven't fixed it, get in touch.

Need this done on a real stack?

Magento 2, Adobe Commerce, migrations to Medusa.js or Vendure, enterprise Next.js, WordPress, and AI automation.

Contact us