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.
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.
Why it happens
- Visibility is set to Not Visible Individually, often because the product is meant to be a child of a configurable or bundle, but it is being checked directly.
- A REST API product create or update call omitted
extension_attributes.website_ids, which several long-standing Magento issues show can silently default, strip, or fail to set the website link entirely, leaving the product Enabled but attached to no website the storefront reads from. - The product's only linked category has been disabled, moved under a disabled parent, or excluded from the navigation, so there is no active, reachable path to it even though the product itself is fine.
- Everything above is actually correct, but the catalog indexers,
catalog_category_product,catalog_product_index, orcatalogsearch_fulltext, have not run against the fix yet because Update by Schedule cron is not running or an indexer is stuck invalid or processing, so the storefront keeps serving the old, stale index data.
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.
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.
Build it step by step
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.
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
// 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
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.
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()
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();
}
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}.
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"))
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);
}
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.
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}
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 };
}
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.
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"}
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" };
}
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.
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.
"""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 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.
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
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
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.
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.
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:
- Adobe Commerce Knowledge Base: product is not displayed on storefront. experienceleague.adobe.com product-is-not-displayed-on-storefront
- GitHub Issue: updating a product via the REST API assigns it to all websites automatically. github.com/magento/magento2/issues/11324
- GitHub Issue: creating a product via REST API does not assign it to the website. github.com/magento/magento2/issues/8173
On the solution:
- Adobe Commerce: manage the indexers, including indexer:reindex and indexer:status. experienceleague.adobe.com manage indexers
- Adobe Commerce: Products endpoint reference. developer.adobe.com/commerce/webapi/rest/quick-reference/products
- 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.
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