Skip to content

Diagnostic Catalog and Visibility

Enabled product missing from the Magento 2 storefront

The admin grid says Enabled. You open the product, everything looks fine, and yet the storefront acts like it does not exist. No search result, no category listing, a 404 on the direct URL. Status is only one of several conditions Magento checks before it will show a product, and any one of the others failing is enough to hide it completely. Here is what actually decides storefront visibility and a small script that checks each condition in order so you know exactly which one is broken.

Python and Node.js REST API diagnostic Safe by default (dry run)
A grocery aisle
Photo by Brad on Unsplash
The short answer

A product is only eligible for the storefront when three conditions are all true at once: visibility includes catalog or search rather than Not Visible Individually, the product carries the storefront's website_id in its website assignment, and it links to at least one category that itself has is_active true. Even when all three are correct, the storefront can still be wrong if the catalog_category_product, catalog_product_index, or catalogsearch_fulltext indexer is stale or cron has stopped running. A script can check the first three over REST and flag the last as an indexer or cron suspect, since reindexing is CLI only. Full code, tests, and a dry run guard are below.

The problem in plain words

Enabled is the first gate a product has to pass, and it is the one everyone checks first because it is right there in the admin grid. But Enabled only means the product is allowed to exist in the catalog at all. It says nothing about whether a shopper can find it.

Magento actually needs several independent things to line up before a product shows up anywhere on the storefront: the product's visibility has to include catalog or search, the product has to be assigned to the website that storefront belongs to, and it has to sit under a category that is itself active and reachable. Then, on top of all of that, the catalog indexers have to have actually run against the corrected data. Miss any one of these and the product is invisible, while the admin keeps insisting it is Enabled.

Status Enabled (checked) Visibility Not Visible Individually Website website_id missing Category not active Any one fails product stays hidden Even if fixed index may be stale Storefront still missing it
Enabled is checked in the admin grid, but visibility, website assignment, and an active category all have to agree, and even then a stale index can still hide the product.

Why it happens

This exact combination shows up repeatedly in Magento's own issue tracker, where updating a product through the REST API without the website extension attribute reassigns or drops websites unexpectedly, and where creating a product over the API leaves it unattached to any website at all. See the citations at the end for the specific issues.

The key insight

Because these conditions are independent, checking only status tells you almost nothing. The useful move is to check visibility, website assignment, and category activation as three separate booleans and combine them with AND, then only after that ask a fourth question: does the live storefront actually match what the data says it should show. If the data says the product should be eligible but the storefront disagrees, that mismatch itself is the diagnosis, it means the index is stale or cron missed a run, not that the data is wrong.

The fix, as a flow

We do not blindly rewrite product data. We add a check that pulls the product, its website links, and its linked categories, runs them through one pure decision function, and only then compares that verdict against what the live storefront actually shows. A clean mismatch there points straight at the index instead of the data.

Fetch SKU product, websites, categories decideStorefrontEligibility pure decision function Compare to storefront HTTP GET the live page Eligible but still missing? yes no, reasons[] lists why Flag indexer/cron operator runs reindex
The script separates genuinely ineligible products, which get a list of specific reasons, from eligible products the storefront still hides, which point at a stale indexer or a missed cron run.

Build it step by step

1

Get an admin bearer token

Call POST /rest/V1/integration/admin/token with an admin username and password, or use an integration token directly. Keep the store URL, token, and the target website id in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export TARGET_WEBSITE_ID="1"
export DRY_RUN="true"   # start safe, change to false to allow the repair path
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export TARGET_WEBSITE_ID="1"
export DRY_RUN="true"   // start safe, change to false to allow the repair path
2

Talk to the Magento REST API

Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps GET and PUT and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]

def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

Pull the product, its websites, and its categories

Find the product by SKU with a searchCriteria filter, then call /products/{sku}/websites for the website ids and read category_ids off the product's custom attributes so each linked category's is_active flag can be checked with /categories/{id}.

step3.py
def find_product_by_sku(sku):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "sku",
        "searchCriteria[filterGroups][0][filters][0][value]": sku,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    items = magento_get("/products", params)["items"]
    return items[0] if items else None

def product_website_ids(sku):
    return magento_get(f"/products/{sku}/websites")

def category_is_active(category_id):
    return bool(magento_get(f"/categories/{category_id}").get("is_active"))
step3.js
async function findProductBySku(sku) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "sku",
    "searchCriteria[filterGroups][0][filters][0][value]": sku,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  };
  const data = await magentoGet("/products", params);
  return data.items[0] || null;
}

async function productWebsiteIds(sku) {
  return magentoGet(`/products/${sku}/websites`);
}

async function categoryIsActive(categoryId) {
  const category = await magentoGet(`/categories/${categoryId}`);
  return Boolean(category.is_active);
}
4

Decide, with one pure function

Keep the decision in its own function that takes the product's status, visibility, website ids, and category ids, plus the full list of categories and the target website id, and returns whether the product is eligible along with every reason it is not. A pure function like this is easy to read and easy to test, which we do later.

decide.py
NOT_VISIBLE_INDIVIDUALLY = 1

def decide_storefront_eligibility(product, categories, target_website_id):
    reasons = []

    if product["status"] != 1:
        reasons.append("disabled")

    if product["visibility"] == NOT_VISIBLE_INDIVIDUALLY:
        reasons.append("not_visible_individually")

    if target_website_id not in product["websiteIds"]:
        reasons.append("website_not_assigned")

    active_ids = {c["id"] for c in categories if c["isActive"]}
    if not any(cid in active_ids for cid in product["categoryIds"]):
        reasons.append("no_active_category")

    return {"eligible": len(reasons) == 0, "reasons": reasons}
decide.js
const NOT_VISIBLE_INDIVIDUALLY = 1;

export function decideStorefrontEligibility(product, categories, targetWebsiteId) {
  const reasons = [];

  if (product.status !== 1) reasons.push("disabled");
  if (product.visibility === NOT_VISIBLE_INDIVIDUALLY) reasons.push("not_visible_individually");
  if (!product.websiteIds.includes(targetWebsiteId)) reasons.push("website_not_assigned");

  const activeIds = new Set(categories.filter((c) => c.isActive).map((c) => c.id));
  const hasActiveCategory = product.categoryIds.some((id) => activeIds.has(id));
  if (!hasActiveCategory) reasons.push("no_active_category");

  return { eligible: reasons.length === 0, reasons };
}
5

Cross check against the live storefront

Fetch the storefront-facing product or category URL directly. When the decision says eligible but the page comes back missing, that mismatch is itself the finding: nothing in the data explains the absence, so the likely cause is a stale or invalid indexer, or a cron run that never happened.

crosscheck.py
SUSPECT_INDEXERS = ["catalog_category_product", "catalog_product_index", "catalogsearch_fulltext"]

def storefront_has_product(storefront_url):
    r = requests.get(storefront_url, timeout=15, allow_redirects=True)
    return r.status_code == 200

def classify(sku, verdict, storefront_url):
    live = storefront_has_product(storefront_url)
    if verdict["eligible"] and not live:
        return {"sku": sku, "status": "indexer_or_cron_suspected", "suspects": SUSPECT_INDEXERS}
    if not verdict["eligible"]:
        return {"sku": sku, "status": "ineligible", "reasons": verdict["reasons"]}
    return {"sku": sku, "status": "ok"}
crosscheck.js
const SUSPECT_INDEXERS = ["catalog_category_product", "catalog_product_index", "catalogsearch_fulltext"];

async function storefrontHasProduct(storefrontUrl) {
  const res = await fetch(storefrontUrl, { redirect: "follow" });
  return res.status === 200;
}

async function classify(sku, verdict, storefrontUrl) {
  const live = await storefrontHasProduct(storefrontUrl);
  if (verdict.eligible && !live) {
    return { sku, status: "indexer_or_cron_suspected", suspects: SUSPECT_INDEXERS };
  }
  if (!verdict.eligible) {
    return { sku, status: "ineligible", reasons: verdict.reasons };
  }
  return { sku, status: "ok" };
}
6

Repair the data conditions, gated behind dry run

When the verdict is ineligible, the repair for status, visibility, or website assignment is a PUT /rest/V1/products/{sku} with the corrected fields, always sending the full extension_attributes.website_ids array rather than a partial one, since omitting it is the documented cause of websites being silently reassigned. Print a diff and require confirmation before writing, and never touch a SKU flagged as indexer or cron suspected, since that repair is CLI only.

Run it safe

Always start with DRY_RUN=true. Never PUT a product with a partial website_ids list, since that is exactly the bug that reassigns or drops websites. And never expect this script to fix a stale indexer, since only bin/magento indexer:reindex can do that.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, checks status, visibility, website assignment, and category activation, cross checks the live storefront, respects the dry run flag, and is safe to run again and again because by default it only reports.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
diagnose_missing_product.py
"""Diagnose a Magento 2 product that shows Enabled but is missing from the storefront.

Status Enabled is only one of several conditions Magento checks. Visibility has
to include catalog or search, the product has to carry the storefront's
website_id in its website assignment (a REST create/update that omits
extension_attributes.website_ids can silently drop or fail to set this, per
magento2 GitHub issues #8173, #10495, #11324), and it has to link to at least
one active category. Even when all three agree, a stale or invalid indexer
(catalog_category_product, catalog_product_index, catalogsearch_fulltext) or a
missed cron run can still hide the product, and that can only be fixed with
bin/magento indexer:reindex, not over REST. This reports by default. Run on a
schedule or on demand. Safe to run again and again.
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("diagnose_missing_product")

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
TARGET_WEBSITE_ID = int(os.environ.get("TARGET_WEBSITE_ID", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

NOT_VISIBLE_INDIVIDUALLY = 1
SUSPECT_INDEXERS = ["catalog_category_product", "catalog_product_index", "catalogsearch_fulltext"]


def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def magento_put(path, body):
    r = requests.put(
        f"{MAGENTO_URL}/rest/V1{path}",
        json=body,
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def decide_storefront_eligibility(product, categories, target_website_id):
    """Pure decision logic, no I/O.

    product: {status: 1|2, visibility: 1|2|3|4, websiteIds: [int], categoryIds: [int]}
    categories: [{id: int, isActive: bool}]
    target_website_id: int
    Returns {"eligible": bool, "reasons": [str]}.
    """
    reasons = []

    if product["status"] != 1:
        reasons.append("disabled")

    if product["visibility"] == NOT_VISIBLE_INDIVIDUALLY:
        reasons.append("not_visible_individually")

    if target_website_id not in product["websiteIds"]:
        reasons.append("website_not_assigned")

    active_ids = {c["id"] for c in categories if c["isActive"]}
    if not any(cid in active_ids for cid in product["categoryIds"]):
        reasons.append("no_active_category")

    return {"eligible": len(reasons) == 0, "reasons": reasons}


def find_product_by_sku(sku):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "sku",
        "searchCriteria[filterGroups][0][filters][0][value]": sku,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    items = magento_get("/products", params)["items"]
    return items[0] if items else None


def visibility_of(product):
    for attr in product.get("custom_attributes", []):
        if attr["attribute_code"] == "visibility":
            return int(attr["value"])
    return None


def category_ids_of(product):
    for attr in product.get("custom_attributes", []):
        if attr["attribute_code"] == "category_ids":
            return [int(c) for c in attr["value"]]
    return []


def product_website_ids(sku):
    return magento_get(f"/products/{sku}/websites")


def fetch_categories(category_ids):
    result = []
    for cid in category_ids:
        data = magento_get(f"/categories/{cid}")
        result.append({"id": cid, "isActive": bool(data.get("is_active"))})
    return result


def storefront_has_product(storefront_url):
    r = requests.get(storefront_url, timeout=15, allow_redirects=True)
    return r.status_code == 200


def build_product_snapshot(sku):
    product = find_product_by_sku(sku)
    if product is None:
        return None
    category_ids = category_ids_of(product)
    snapshot = {
        "status": product["status"],
        "visibility": visibility_of(product),
        "websiteIds": product_website_ids(sku),
        "categoryIds": category_ids,
    }
    categories = fetch_categories(category_ids)
    return snapshot, categories


def diagnose(sku, storefront_url=None):
    built = build_product_snapshot(sku)
    if built is None:
        return {"sku": sku, "status": "not_found"}

    snapshot, categories = built
    verdict = decide_storefront_eligibility(snapshot, categories, TARGET_WEBSITE_ID)

    if not verdict["eligible"]:
        return {"sku": sku, "status": "ineligible", "reasons": verdict["reasons"]}

    if storefront_url and not storefront_has_product(storefront_url):
        return {"sku": sku, "status": "indexer_or_cron_suspected", "suspects": SUSPECT_INDEXERS}

    return {"sku": sku, "status": "ok"}


def repair_product(sku, fixes):
    """fixes may include status, visibility, and/or website_ids (a FULL list).
    Never send a partial website_ids list; that is the documented bug (#11324)
    that reassigns or drops websites.
    """
    body = {"product": {"sku": sku}}
    if "status" in fixes:
        body["product"]["status"] = fixes["status"]
    if "visibility" in fixes:
        body["product"]["visibility"] = fixes["visibility"]
    if "website_ids" in fixes:
        body["product"]["extension_attributes"] = {"website_ids": fixes["website_ids"]}

    log.info("DRY_RUN diff for %s: %s", sku, body)
    if DRY_RUN:
        return {"sku": sku, "applied": False, "dry_run": True, "body": body}

    magento_put(f"/products/{sku}", body)
    return {"sku": sku, "applied": True, "dry_run": False, "body": body}


def run(skus, storefront_urls=None):
    storefront_urls = storefront_urls or {}
    reports = []
    for sku in skus:
        report = diagnose(sku, storefront_urls.get(sku))
        log.info("SKU %s: %s", sku, report["status"])
        reports.append(report)
    log.info("Done. %d SKU(s) checked.", len(reports))
    return reports


if __name__ == "__main__":
    target_skus = [s.strip() for s in os.environ.get("TARGET_SKUS", "").split(",") if s.strip()]
    run(target_skus)
diagnose-missing-product.js
/**
 * Diagnose a Magento 2 product that shows Enabled but is missing from the storefront.
 *
 * Status Enabled is only one of several conditions Magento checks. Visibility
 * has to include catalog or search, the product has to carry the storefront's
 * website_id in its website assignment (a REST create/update that omits
 * extension_attributes.website_ids can silently drop or fail to set this, per
 * magento2 GitHub issues #8173, #10495, #11324), and it has to link to at
 * least one active category. Even when all three agree, a stale or invalid
 * indexer (catalog_category_product, catalog_product_index,
 * catalogsearch_fulltext) or a missed cron run can still hide the product,
 * and that can only be fixed with bin/magento indexer:reindex, not over REST.
 * This reports by default. Run on a schedule or on demand. Safe to run again
 * and again.
 *
 * Guide: https://www.allanninal.dev/magento/enabled-product-missing-from-storefront/
 */
import { pathToFileURL } from "node:url";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const TARGET_WEBSITE_ID = Number(process.env.TARGET_WEBSITE_ID || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const NOT_VISIBLE_INDIVIDUALLY = 1;
const SUSPECT_INDEXERS = ["catalog_category_product", "catalog_product_index", "catalogsearch_fulltext"];

export function decideStorefrontEligibility(product, categories, targetWebsiteId) {
  const reasons = [];

  if (product.status !== 1) reasons.push("disabled");
  if (product.visibility === NOT_VISIBLE_INDIVIDUALLY) reasons.push("not_visible_individually");
  if (!product.websiteIds.includes(targetWebsiteId)) reasons.push("website_not_assigned");

  const activeIds = new Set(categories.filter((c) => c.isActive).map((c) => c.id));
  const hasActiveCategory = product.categoryIds.some((id) => activeIds.has(id));
  if (!hasActiveCategory) reasons.push("no_active_category");

  return { eligible: reasons.length === 0, reasons };
}

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function magentoPut(path, body) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function findProductBySku(sku) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "sku",
    "searchCriteria[filterGroups][0][filters][0][value]": sku,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  };
  const data = await magentoGet("/products", params);
  return data.items[0] || null;
}

function visibilityOf(product) {
  const attr = (product.custom_attributes || []).find((a) => a.attribute_code === "visibility");
  return attr ? Number(attr.value) : null;
}

function categoryIdsOf(product) {
  const attr = (product.custom_attributes || []).find((a) => a.attribute_code === "category_ids");
  return attr ? attr.value.map(Number) : [];
}

async function productWebsiteIds(sku) {
  return magentoGet(`/products/${sku}/websites`);
}

async function fetchCategories(categoryIds) {
  const result = [];
  for (const id of categoryIds) {
    const data = await magentoGet(`/categories/${id}`);
    result.push({ id, isActive: Boolean(data.is_active) });
  }
  return result;
}

async function storefrontHasProduct(storefrontUrl) {
  const res = await fetch(storefrontUrl, { redirect: "follow" });
  return res.status === 200;
}

async function buildProductSnapshot(sku) {
  const product = await findProductBySku(sku);
  if (!product) return null;
  const categoryIds = categoryIdsOf(product);
  const snapshot = {
    status: product.status,
    visibility: visibilityOf(product),
    websiteIds: await productWebsiteIds(sku),
    categoryIds,
  };
  const categories = await fetchCategories(categoryIds);
  return { snapshot, categories };
}

export async function diagnose(sku, storefrontUrl) {
  const built = await buildProductSnapshot(sku);
  if (!built) return { sku, status: "not_found" };

  const { snapshot, categories } = built;
  const verdict = decideStorefrontEligibility(snapshot, categories, TARGET_WEBSITE_ID);

  if (!verdict.eligible) {
    return { sku, status: "ineligible", reasons: verdict.reasons };
  }

  if (storefrontUrl && !(await storefrontHasProduct(storefrontUrl))) {
    return { sku, status: "indexer_or_cron_suspected", suspects: SUSPECT_INDEXERS };
  }

  return { sku, status: "ok" };
}

export async function repairProduct(sku, fixes) {
  // fixes may include status, visibility, and/or websiteIds (a FULL list).
  // Never send a partial websiteIds list; that is the documented bug (#11324)
  // that reassigns or drops websites.
  const body = { product: { sku } };
  if ("status" in fixes) body.product.status = fixes.status;
  if ("visibility" in fixes) body.product.visibility = fixes.visibility;
  if ("websiteIds" in fixes) body.product.extension_attributes = { website_ids: fixes.websiteIds };

  console.log(`DRY_RUN diff for ${sku}:`, JSON.stringify(body));
  if (DRY_RUN) return { sku, applied: false, dryRun: true, body };

  await magentoPut(`/products/${sku}`, body);
  return { sku, applied: true, dryRun: false, body };
}

export async function run(skus, storefrontUrls = {}) {
  const reports = [];
  for (const sku of skus) {
    const report = await diagnose(sku, storefrontUrls[sku]);
    console.log(`SKU ${sku}: ${report.status}`);
    reports.push(report);
  }
  console.log(`Done. ${reports.length} SKU(s) checked.`);
  return reports;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const targetSkus = (process.env.TARGET_SKUS || "").split(",").map((s) => s.trim()).filter(Boolean);
  run(targetSkus).catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides whether a SKU is genuinely ineligible or points to a stale index instead. Because we kept decide_storefront_eligibility pure, the test needs no network, no store, and no database. It just feeds in plain objects and checks the answer.

test_eligibility.py
from diagnose_missing_product import decide_storefront_eligibility

CATEGORIES = [{"id": 2, "isActive": True}, {"id": 9, "isActive": False}]


def product(**over):
    base = {"status": 1, "visibility": 4, "websiteIds": [1], "categoryIds": [2]}
    base.update(over)
    return base


def test_eligible_when_all_conditions_pass():
    result = decide_storefront_eligibility(product(), CATEGORIES, 1)
    assert result == {"eligible": True, "reasons": []}


def test_disabled_is_flagged():
    result = decide_storefront_eligibility(product(status=2), CATEGORIES, 1)
    assert result["eligible"] is False
    assert "disabled" in result["reasons"]


def test_not_visible_individually_is_flagged():
    result = decide_storefront_eligibility(product(visibility=1), CATEGORIES, 1)
    assert "not_visible_individually" in result["reasons"]


def test_website_not_assigned_is_flagged():
    result = decide_storefront_eligibility(product(websiteIds=[2]), CATEGORIES, 1)
    assert "website_not_assigned" in result["reasons"]


def test_no_active_category_is_flagged():
    result = decide_storefront_eligibility(product(categoryIds=[9]), CATEGORIES, 1)
    assert "no_active_category" in result["reasons"]


def test_multiple_failures_all_listed():
    result = decide_storefront_eligibility(
        product(status=2, visibility=1, websiteIds=[], categoryIds=[9]), CATEGORIES, 1
    )
    assert set(result["reasons"]) == {
        "disabled", "not_visible_individually", "website_not_assigned", "no_active_category",
    }


def test_eligible_with_at_least_one_active_category_among_several():
    result = decide_storefront_eligibility(product(categoryIds=[9, 2]), CATEGORIES, 1)
    assert result["eligible"] is True
eligibility.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideStorefrontEligibility } from "./diagnose-missing-product.js";

const CATEGORIES = [{ id: 2, isActive: true }, { id: 9, isActive: false }];

const product = (over = {}) => ({ status: 1, visibility: 4, websiteIds: [1], categoryIds: [2], ...over });

test("eligible when all conditions pass", () => {
  const result = decideStorefrontEligibility(product(), CATEGORIES, 1);
  assert.deepEqual(result, { eligible: true, reasons: [] });
});

test("disabled is flagged", () => {
  const result = decideStorefrontEligibility(product({ status: 2 }), CATEGORIES, 1);
  assert.equal(result.eligible, false);
  assert.ok(result.reasons.includes("disabled"));
});

test("not visible individually is flagged", () => {
  const result = decideStorefrontEligibility(product({ visibility: 1 }), CATEGORIES, 1);
  assert.ok(result.reasons.includes("not_visible_individually"));
});

test("website not assigned is flagged", () => {
  const result = decideStorefrontEligibility(product({ websiteIds: [2] }), CATEGORIES, 1);
  assert.ok(result.reasons.includes("website_not_assigned"));
});

test("no active category is flagged", () => {
  const result = decideStorefrontEligibility(product({ categoryIds: [9] }), CATEGORIES, 1);
  assert.ok(result.reasons.includes("no_active_category"));
});

test("multiple failures all listed", () => {
  const result = decideStorefrontEligibility(
    product({ status: 2, visibility: 1, websiteIds: [], categoryIds: [9] }), CATEGORIES, 1
  );
  assert.deepEqual(
    new Set(result.reasons),
    new Set(["disabled", "not_visible_individually", "website_not_assigned", "no_active_category"])
  );
});

test("eligible with at least one active category among several", () => {
  const result = decideStorefrontEligibility(product({ categoryIds: [9, 2] }), CATEGORIES, 1);
  assert.equal(result.eligible, true);
});

Case studies

REST website drop

The integration that silently unassigned a website

A middleware sync updated prices on hundreds of SKUs through PUT /rest/V1/products/{sku}, sending only the fields it cared about. Because it never included extension_attributes.website_ids, a batch of products lost their website link on the storefront the sync was supposed to be updating, while a second storefront on a different website kept working, which made the bug look random for a week.

Running the diagnostic across the affected SKUs immediately surfaced website_not_assigned on every one of them. The fix was updating the middleware to always send the full website id array, and a one-time repair run with a printed diff to reattach the missing products.

Stale index

The category fix that did not show up for an hour

A merchandiser corrected a product's category and visibility directly in the admin, expecting it to appear right away. On Update by Schedule, nothing changed on the storefront, and the merchandiser assumed the edit had not saved.

The diagnostic showed the product as eligible with no reasons listed, but the live storefront check still came back missing, which flagged it as indexer or cron suspected. That pointed straight at catalog_category_product, and running bin/magento indexer:reindex catalog_category_product resolved it in under a minute, confirming the data was never the problem.

What good looks like

After this runs, a missing product stops being a guessing game between the admin and the storefront. Every SKU comes back with either a clear list of failing conditions or a direct pointer at the indexer and cron layer, so nobody wastes an afternoon re-saving a product that was already correct. Keep the repair path gated behind a printed diff, since that is what keeps a website fix from turning into an accidental website removal.

FAQ

Why does an Enabled product still not show up on the storefront?

Status alone is not enough. The product also needs a visibility that includes catalog or search, it needs to carry the storefront's website_id in its website assignment, and it needs to be linked to at least one category that is itself active. All three have to be true at the same time, and any one of them failing hides the product even though the admin grid says Enabled.

Why did my REST API product update remove it from the website?

Several long-standing Magento issues show that creating or updating a product through the REST API without sending extension_attributes.website_ids can silently drop or fail to set the website link. The product still saves and still shows Enabled, but it is no longer attached to the website the storefront reads from, so it disappears from that store view.

The data all looks correct, so why is the product still missing?

If status, visibility, website assignment, and category are all correct in the API response but the storefront still does not show the product, the cause is almost always a stale or invalid indexer, such as catalog_category_product, catalog_product_index, or catalogsearch_fulltext, or a cron run that never happened. Reindexing is a CLI operation, so this has to be fixed with bin/magento indexer:reindex rather than through the REST API.

Related field notes

Citations

On the problem:

  1. Adobe Commerce Knowledge Base: product is not displayed on storefront. experienceleague.adobe.com product-is-not-displayed-on-storefront
  2. GitHub Issue: updating a product via the REST API assigns it to all websites automatically. github.com/magento/magento2/issues/11324
  3. GitHub Issue: creating a product via REST API does not assign it to the website. github.com/magento/magento2/issues/8173

On the solution:

  1. Adobe Commerce: manage the indexers, including indexer:reindex and indexer:status. experienceleague.adobe.com manage indexers
  2. Adobe Commerce: Products endpoint reference. developer.adobe.com/commerce/webapi/rest/quick-reference/products
  3. Adobe Commerce Knowledge Base: product is not displayed on storefront. experienceleague.adobe.com product-is-not-displayed-on-storefront

Stuck on a tricky one?

If you have a problem in Magento 2 or Adobe Commerce catalog visibility, orders, or inventory that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this find your missing product?

If this saved you an afternoon of re-saving a product that was already correct, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Magento field notes