Reconciler Storefront Visibility

Products missing a product_visibility row for a sales channel

The product is active, it has stock, it sits in the right category, and it is assigned to the sales channel in the Admin. Everything about it looks correct. Yet it never shows up in that channel's storefront, search, or category pages. Here is why Shopware leaves these products invisible and a small script that finds the exact products and channels missing the row, then repairs it safely.

Python and Node.js Shopware Admin API Safe by default (dry run)
The short answer

In Shopware 6, storefront visibility is not implied by category or sales channel assignment. It requires an explicit row in product_visibility with the product id, the sales channel id, and a visibility value, and the Storefront and Store API listing routes filter on that row. Products created through the Admin sync API, third-party import tools, or migrated from Shopware 5 or another platform frequently only write the product and its associations but skip the productVisibilities child entity, because the sync API has historically only inserted visibilities on first-time upsert. Run a small Python or Node.js script that searches for products with zero product_visibility rows for each required sales channel, and, only behind a dry run flag, creates the missing row with visibility: 30. Full code, tests, and sources are below.

The problem in plain words

It feels natural to assume that assigning a product to a sales channel is what makes it visible there. It is not. Shopware keeps that decision in a separate child table, product_visibility, keyed by the product id and the sales channel id, with a visibility value that controls how much of the product shows up (search only, listing, or all). The Storefront and Store API product-listing route joins against that table. No row for that product and that channel means the join returns nothing, no matter how correct everything else about the product looks.

Most of the time this row gets created for you automatically, the moment you assign a product to a sales channel in the Admin UI. But a lot of products in a real store are never created that way. They come in through the Admin sync API, a third-party import tool, or a migration from Shopware 5 or another platform, and those paths write the product and its category and sales channel associations but can silently skip the productVisibilities child entity.

Sync API, import, or migration creates the product Product looks fine active, in stock, channel assigned product_visibility row never written No visibility row for this channel Never shows in storefront
The product itself is created correctly. What goes missing is the separate join row that tells the Storefront API this product belongs in that channel's listings.

Why it happens

This is a gap between what an import writes and what the Storefront actually reads. A few common ways stores end up with products missing this row:

None of this throws a visible error. The product passes every check a merchant would think to make in the Admin: active, in stock, assigned to the channel, in the right category. The only place the gap shows up is the storefront itself, where the product simply is not there, and nothing in the Admin points back at product_visibility as the reason.

The key insight

Category assignment and sales channel assignment describe where a product could belong. Only product_visibility decides whether it is actually shown there. The fix is not to touch the product, its categories, or its channel assignment at all. It is to check, per product and per required sales channel, whether that one join row exists, and if it does not, create it, the same composite-key insert the Admin UI performs automatically when you assign visibility by hand.

The fix, as a flow

We do not change the product, its categories, or its active status. The script lists the sales channels a store cares about, searches for products that have zero product_visibility rows for each one, and, only for products that are already active, creates the missing row with visibility: 30 (VISIBILITY_ALL). Before writing, it re-checks that the row is still absent, since Shopware's sync API is known to error on re-inserting an existing visibility.

List sales channels the store cares about Search products missing a visibility row planVisibilityRepairs pure decision function Product is active? yes, re-check and insert no, skip (draft, disabled) POST product-visibility visibility: 30, only if not DRY_RUN
The pure function only ever proposes inserting a visibility row for a product that is already active. Disabled and draft products are always left alone.

Build it step by step

1

Get Admin API credentials

Create an Integration in your Shopware Administration under Settings, System, Integrations. Copy the access key id and secret access key. The script exchanges these for a bearer token at POST /api/oauth/token with a client_credentials grant, so keep them in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export REQUIRED_SALES_CHANNEL_IDS="a1b2c3...,d4e5f6..."
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export REQUIRED_SALES_CHANNEL_IDS="a1b2c3...,d4e5f6..."
export DRY_RUN="true"   // start safe, change to false to write
2

Authenticate and talk to the Admin API

A small helper trades the client id and secret for an access token, then sends it as Authorization: Bearer <token> on every call. We reuse this helper for the search and for the create calls.

step2.py
import os, requests

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]

def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def api(method, path, token, json_body=None):
    r = requests.request(
        method,
        f"{SHOPWARE_URL}{path}",
        json=json_body,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}
step2.js
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      grant_type: "client_credentials",
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function api(method, path, token, jsonBody) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: jsonBody ? JSON.stringify(jsonBody) : undefined,
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Search for products missing a visibility row for a channel

For each required sales channel id, search product for parent products (parentId equals null, so variants are not double-counted) where a not filter negates an equals match against visibilities.salesChannelId. That negated association match nets out to a NOT EXISTS against the child product_visibility table for that channel. Page through with page and limit, reading total-count-mode:1 to know when to stop.

step3.py
def search_products_missing_visibility(token, sales_channel_id, page=1, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "filter": [
            {"type": "equals", "field": "parentId", "value": None},
            {
                "type": "not",
                "operator": "AND",
                "queries": [
                    {"type": "equals", "field": "visibilities.salesChannelId", "value": sales_channel_id}
                ],
            },
        ],
        "associations": {"visibilities": {}},
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/product", token, body)
step3.js
async function searchProductsMissingVisibility(token, salesChannelId, page = 1, limit = 500) {
  const body = {
    page,
    limit,
    filter: [
      { type: "equals", field: "parentId", value: null },
      {
        type: "not",
        operator: "AND",
        queries: [
          { type: "equals", field: "visibilities.salesChannelId", value: salesChannelId },
        ],
      },
    ],
    associations: { visibilities: {} },
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/product", token, body);
}
4

Decide, with one pure function

Keep the decision in its own function that takes the products found by the search, the sales channels the store requires, and a default visibility value, and returns the flat list of missing pairs to insert. It builds a set of channel ids the product already has, emits a repair record for each required channel not in that set, and skips any product where active is false, since a disabled or draft product should never be surfaced by an automated repair.

decide.py
def plan_visibility_repairs(products, required_channels, default_visibility=30):
    seen = set()
    repairs = []
    for product in products:
        if not product.get("active", False):
            continue
        existing_channel_ids = {
            v["salesChannelId"] for v in (product.get("existingVisibilities") or [])
        }
        for channel in required_channels:
            if channel["id"] in existing_channel_ids:
                continue
            key = (product["id"], channel["id"])
            if key in seen:
                continue
            seen.add(key)
            repairs.append({
                "productId": product["id"],
                "salesChannelId": channel["id"],
                "visibility": channel.get("defaultVisibility", default_visibility),
            })
    return repairs
decide.js
export function planVisibilityRepairs(products, requiredChannels, defaultVisibility = 30) {
  const seen = new Set();
  const repairs = [];
  for (const product of products) {
    if (!product.active) continue;
    const existingChannelIds = new Set(
      (product.existingVisibilities || []).map((v) => v.salesChannelId)
    );
    for (const channel of requiredChannels) {
      if (existingChannelIds.has(channel.id)) continue;
      const key = `${product.id}:${channel.id}`;
      if (seen.has(key)) continue;
      seen.add(key);
      repairs.push({
        productId: product.id,
        salesChannelId: channel.id,
        visibility: channel.defaultVisibility ?? defaultVisibility,
      });
    }
  }
  return repairs;
}
5

Re-verify, then create the missing row

Before writing, re-run the search filter for that exact product and channel pair to confirm the row is still absent. Shopware's sync API is known to error on re-inserting an existing visibility, so this guard avoids a duplicate-key failure if something else created the row between the plan and the write. Then POST /api/product-visibility with the product id, sales channel id, and visibility. Since productId and salesChannelId form the composite primary key, this is a plain create, never a PATCH.

apply.py
def visibility_row_exists(token, product_id, sales_channel_id):
    body = {
        "page": 1,
        "limit": 1,
        "filter": [
            {"type": "equals", "field": "productId", "value": product_id},
            {"type": "equals", "field": "salesChannelId", "value": sales_channel_id},
        ],
        "total-count-mode": 1,
    }
    data = api("POST", "/api/search/product-visibility", token, body)
    return data.get("total", 0) > 0

def create_visibility(token, product_id, sales_channel_id, visibility):
    body = {"productId": product_id, "salesChannelId": sales_channel_id, "visibility": visibility}
    api("POST", "/api/product-visibility", token, body)
apply.js
async function visibilityRowExists(token, productId, salesChannelId) {
  const body = {
    page: 1,
    limit: 1,
    filter: [
      { type: "equals", field: "productId", value: productId },
      { type: "equals", field: "salesChannelId", value: salesChannelId },
    ],
    "total-count-mode": 1,
  };
  const data = await api("POST", "/api/search/product-visibility", token, body);
  return (data.total || 0) > 0;
}

async function createVisibility(token, productId, salesChannelId, visibility) {
  const body = { productId, salesChannelId, visibility };
  await api("POST", "/api/product-visibility", token, body);
}
6

Wire it together with a dry run guard

The run loop lists the required sales channels, searches each one for products missing a row, plans the repairs with the pure function, and only when DRY_RUN is off does it re-verify and create each row. Run it on a schedule that matches how often products are imported or synced, for example after every import job finishes.

Run it safe

Always start with DRY_RUN=true. Never touch a product where active is false, and never bulk-insert without re-verifying the row is still absent right before the write, since Shopware's sync API is known to error on re-inserting an existing visibility (see shopware/shopware#595).

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and is safe to run again and again because it only ever creates a visibility row for an active product and a required channel where the row is confirmed absent right before the write.

View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.

repair_missing_visibility.py
"""Find Shopware 6 products missing a product_visibility row, and repair them, safely.

Storefront visibility is not implied by category or sales channel assignment. It requires
a separate row in product_visibility keyed by productId and salesChannelId, and the
Storefront and Store API listing routes filter on that row. Products created through the
Admin sync API, third-party import tools, or a migration frequently write the product and
its associations but skip the productVisibilities child entity, because the sync API has
historically only inserted visibilities on first-time upsert (see shopware/shopware#595).
This script searches for products with zero product_visibility rows for each required
sales channel, and, only behind an explicit DRY_RUN=false flag, re-verifies the row is
still absent and creates it with visibility: 30 (VISIBILITY_ALL). It never touches a
product where active is false. Run on a schedule. 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("repair_missing_visibility")

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
REQUIRED_SALES_CHANNEL_IDS = [
    c.strip() for c in os.environ.get("REQUIRED_SALES_CHANNEL_IDS", "").split(",") if c.strip()
]
DEFAULT_VISIBILITY = int(os.environ.get("DEFAULT_VISIBILITY", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def api(method, path, token, json_body=None):
    r = requests.request(
        method,
        f"{SHOPWARE_URL}{path}",
        json=json_body,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}


def search_products_missing_visibility(token, sales_channel_id, page=1, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "filter": [
            {"type": "equals", "field": "parentId", "value": None},
            {
                "type": "not",
                "operator": "AND",
                "queries": [
                    {"type": "equals", "field": "visibilities.salesChannelId", "value": sales_channel_id}
                ],
            },
        ],
        "associations": {"visibilities": {}},
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/product", token, body)


def plan_visibility_repairs(products, required_channels, default_visibility=30):
    seen = set()
    repairs = []
    for product in products:
        if not product.get("active", False):
            continue
        existing_channel_ids = {
            v["salesChannelId"] for v in (product.get("existingVisibilities") or [])
        }
        for channel in required_channels:
            if channel["id"] in existing_channel_ids:
                continue
            key = (product["id"], channel["id"])
            if key in seen:
                continue
            seen.add(key)
            repairs.append({
                "productId": product["id"],
                "salesChannelId": channel["id"],
                "visibility": channel.get("defaultVisibility", default_visibility),
            })
    return repairs


def visibility_row_exists(token, product_id, sales_channel_id):
    body = {
        "page": 1,
        "limit": 1,
        "filter": [
            {"type": "equals", "field": "productId", "value": product_id},
            {"type": "equals", "field": "salesChannelId", "value": sales_channel_id},
        ],
        "total-count-mode": 1,
    }
    data = api("POST", "/api/search/product-visibility", token, body)
    return data.get("total", 0) > 0


def create_visibility(token, product_id, sales_channel_id, visibility):
    body = {"productId": product_id, "salesChannelId": sales_channel_id, "visibility": visibility}
    api("POST", "/api/product-visibility", token, body)


def fetch_products_missing_visibility(token, sales_channel_id):
    products = []
    page = 1
    while True:
        data = search_products_missing_visibility(token, sales_channel_id, page=page)
        batch = data.get("data", [])
        for row in batch:
            visibilities = row.get("visibilities") or []
            products.append({
                "id": row["id"],
                "productNumber": row.get("productNumber", ""),
                "active": row.get("active", False),
                "existingVisibilities": [
                    {"salesChannelId": v["salesChannelId"], "visibility": v.get("visibility", 30)}
                    for v in visibilities
                ],
            })
        if not batch or page * 500 >= data.get("total", 0):
            break
        page += 1
    return products


def run():
    if not REQUIRED_SALES_CHANNEL_IDS:
        raise RuntimeError("Set REQUIRED_SALES_CHANNEL_IDS before running.")
    token = get_token()

    planned = 0
    created = 0

    for sales_channel_id in REQUIRED_SALES_CHANNEL_IDS:
        products = fetch_products_missing_visibility(token, sales_channel_id)
        required_channels = [{"id": sales_channel_id, "defaultVisibility": DEFAULT_VISIBILITY}]
        repairs = plan_visibility_repairs(products, required_channels, DEFAULT_VISIBILITY)

        for repair in repairs:
            planned += 1
            log.info(
                "Missing visibility productId=%s salesChannelId=%s visibility=%s. %s",
                repair["productId"], repair["salesChannelId"], repair["visibility"],
                "would create" if DRY_RUN else "creating",
            )
            if DRY_RUN:
                continue
            if visibility_row_exists(token, repair["productId"], repair["salesChannelId"]):
                log.info("Row appeared since planning, skipping to avoid a duplicate-key error.")
                continue
            create_visibility(token, repair["productId"], repair["salesChannelId"], repair["visibility"])
            created += 1

    log.info("Done. %d row(s) %s.", planned if DRY_RUN else created, "to create" if DRY_RUN else "created")


if __name__ == "__main__":
    run()
repair-missing-visibility.js
/**
 * Find Shopware 6 products missing a product_visibility row, and repair them, safely.
 *
 * Storefront visibility is not implied by category or sales channel assignment. It requires
 * a separate row in product_visibility keyed by productId and salesChannelId, and the
 * Storefront and Store API listing routes filter on that row. Products created through the
 * Admin sync API, third-party import tools, or a migration frequently write the product and
 * its associations but skip the productVisibilities child entity, because the sync API has
 * historically only inserted visibilities on first-time upsert (see shopware/shopware#595).
 * This script searches for products with zero product_visibility rows for each required
 * sales channel, and, only behind an explicit DRY_RUN=false flag, re-verifies the row is
 * still absent and creates it with visibility: 30 (VISIBILITY_ALL). It never touches a
 * product where active is false. Run on a schedule. Safe to run again and again.
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://demo.example.com").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "SWIAxxx";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "secret_dummy";
const REQUIRED_SALES_CHANNEL_IDS = (process.env.REQUIRED_SALES_CHANNEL_IDS || "")
  .split(",")
  .map((c) => c.trim())
  .filter(Boolean);
const DEFAULT_VISIBILITY = Number(process.env.DEFAULT_VISIBILITY || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function planVisibilityRepairs(products, requiredChannels, defaultVisibility = 30) {
  const seen = new Set();
  const repairs = [];
  for (const product of products) {
    if (!product.active) continue;
    const existingChannelIds = new Set(
      (product.existingVisibilities || []).map((v) => v.salesChannelId)
    );
    for (const channel of requiredChannels) {
      if (existingChannelIds.has(channel.id)) continue;
      const key = `${product.id}:${channel.id}`;
      if (seen.has(key)) continue;
      seen.add(key);
      repairs.push({
        productId: product.id,
        salesChannelId: channel.id,
        visibility: channel.defaultVisibility ?? defaultVisibility,
      });
    }
  }
  return repairs;
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      grant_type: "client_credentials",
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function api(method, path, token, jsonBody) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: jsonBody ? JSON.stringify(jsonBody) : undefined,
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function searchProductsMissingVisibility(token, salesChannelId, page = 1, limit = 500) {
  const body = {
    page,
    limit,
    filter: [
      { type: "equals", field: "parentId", value: null },
      {
        type: "not",
        operator: "AND",
        queries: [
          { type: "equals", field: "visibilities.salesChannelId", value: salesChannelId },
        ],
      },
    ],
    associations: { visibilities: {} },
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/product", token, body);
}

async function visibilityRowExists(token, productId, salesChannelId) {
  const body = {
    page: 1,
    limit: 1,
    filter: [
      { type: "equals", field: "productId", value: productId },
      { type: "equals", field: "salesChannelId", value: salesChannelId },
    ],
    "total-count-mode": 1,
  };
  const data = await api("POST", "/api/search/product-visibility", token, body);
  return (data.total || 0) > 0;
}

async function createVisibility(token, productId, salesChannelId, visibility) {
  const body = { productId, salesChannelId, visibility };
  await api("POST", "/api/product-visibility", token, body);
}

async function fetchProductsMissingVisibility(token, salesChannelId) {
  const products = [];
  let page = 1;
  while (true) {
    const data = await searchProductsMissingVisibility(token, salesChannelId, page);
    const batch = data.data || [];
    for (const row of batch) {
      const visibilities = row.visibilities || [];
      products.push({
        id: row.id,
        productNumber: row.productNumber || "",
        active: row.active || false,
        existingVisibilities: visibilities.map((v) => ({
          salesChannelId: v.salesChannelId,
          visibility: v.visibility ?? 30,
        })),
      });
    }
    if (batch.length === 0 || page * 500 >= (data.total || 0)) break;
    page++;
  }
  return products;
}

export async function run() {
  if (REQUIRED_SALES_CHANNEL_IDS.length === 0) {
    throw new Error("Set REQUIRED_SALES_CHANNEL_IDS before running.");
  }
  const token = await getToken();

  let planned = 0;
  let created = 0;

  for (const salesChannelId of REQUIRED_SALES_CHANNEL_IDS) {
    const products = await fetchProductsMissingVisibility(token, salesChannelId);
    const requiredChannels = [{ id: salesChannelId, defaultVisibility: DEFAULT_VISIBILITY }];
    const repairs = planVisibilityRepairs(products, requiredChannels, DEFAULT_VISIBILITY);

    for (const repair of repairs) {
      planned++;
      console.log(
        `Missing visibility productId=${repair.productId} salesChannelId=${repair.salesChannelId} visibility=${repair.visibility}. ${DRY_RUN ? "would create" : "creating"}`
      );
      if (DRY_RUN) continue;
      if (await visibilityRowExists(token, repair.productId, repair.salesChannelId)) {
        console.log("Row appeared since planning, skipping to avoid a duplicate-key error.");
        continue;
      }
      await createVisibility(token, repair.productId, repair.salesChannelId, repair.visibility);
      created++;
    }
  }

  console.log(`Done. ${DRY_RUN ? planned : created} row(s) ${DRY_RUN ? "to create" : "created"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides which products get an automatic visibility row. Because plan_visibility_repairs is pure, no network and no Shopware instance is needed. It just feeds in plain fixtures of products and channels and checks the answer.

test_product_visibility_plan.py
from repair_missing_visibility import plan_visibility_repairs


def product(**over):
    base = {"id": "a" * 32, "productNumber": "SKU-1", "active": True, "existingVisibilities": []}
    base.update(over)
    return base


def channel(channel_id, default_visibility=None):
    c = {"id": channel_id}
    if default_visibility is not None:
        c["defaultVisibility"] = default_visibility
    return c


def test_repairs_missing_channel_for_active_product():
    repairs = plan_visibility_repairs([product()], [channel("c" * 32)], 30)
    assert repairs == [{"productId": "a" * 32, "salesChannelId": "c" * 32, "visibility": 30}]


def test_skips_channel_already_present():
    p = product(existingVisibilities=[{"salesChannelId": "c" * 32, "visibility": 30}])
    repairs = plan_visibility_repairs([p], [channel("c" * 32)], 30)
    assert repairs == []


def test_skips_inactive_product():
    p = product(active=False)
    repairs = plan_visibility_repairs([p], [channel("c" * 32)], 30)
    assert repairs == []


def test_uses_channel_default_visibility_when_set():
    repairs = plan_visibility_repairs([product()], [channel("c" * 32, default_visibility=20)], 30)
    assert repairs == [{"productId": "a" * 32, "salesChannelId": "c" * 32, "visibility": 20}]


def test_emits_one_repair_per_missing_channel():
    repairs = plan_visibility_repairs(
        [product()],
        [channel("c" * 32), channel("d" * 32)],
        30,
    )
    keys = sorted((r["productId"], r["salesChannelId"]) for r in repairs)
    assert keys == sorted([("a" * 32, "c" * 32), ("a" * 32, "d" * 32)])


def test_deduplicates_by_composite_key():
    p = product()
    repairs = plan_visibility_repairs([p, p], [channel("c" * 32)], 30)
    assert repairs == [{"productId": "a" * 32, "salesChannelId": "c" * 32, "visibility": 30}]
visibility-plan.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { planVisibilityRepairs } from "./repair-missing-visibility.js";

const product = (over = {}) => ({
  id: "a".repeat(32),
  productNumber: "SKU-1",
  active: true,
  existingVisibilities: [],
  ...over,
});

const channel = (id, defaultVisibility) => {
  const c = { id };
  if (defaultVisibility !== undefined) c.defaultVisibility = defaultVisibility;
  return c;
};

test("repairs missing channel for active product", () => {
  const repairs = planVisibilityRepairs([product()], [channel("c".repeat(32))], 30);
  assert.deepEqual(repairs, [{ productId: "a".repeat(32), salesChannelId: "c".repeat(32), visibility: 30 }]);
});

test("skips channel already present", () => {
  const p = product({ existingVisibilities: [{ salesChannelId: "c".repeat(32), visibility: 30 }] });
  const repairs = planVisibilityRepairs([p], [channel("c".repeat(32))], 30);
  assert.deepEqual(repairs, []);
});

test("skips inactive product", () => {
  const p = product({ active: false });
  const repairs = planVisibilityRepairs([p], [channel("c".repeat(32))], 30);
  assert.deepEqual(repairs, []);
});

test("uses channel default visibility when set", () => {
  const repairs = planVisibilityRepairs([product()], [channel("c".repeat(32), 20)], 30);
  assert.deepEqual(repairs, [{ productId: "a".repeat(32), salesChannelId: "c".repeat(32), visibility: 20 }]);
});

test("emits one repair per missing channel", () => {
  const repairs = planVisibilityRepairs([product()], [channel("c".repeat(32)), channel("d".repeat(32))], 30);
  const keys = repairs.map((r) => `${r.productId}:${r.salesChannelId}`).sort();
  assert.deepEqual(keys, [`${"a".repeat(32)}:${"c".repeat(32)}`, `${"a".repeat(32)}:${"d".repeat(32)}`].sort());
});

test("deduplicates by composite key", () => {
  const p = product();
  const repairs = planVisibilityRepairs([p, p], [channel("c".repeat(32))], 30);
  assert.deepEqual(repairs, [{ productId: "a".repeat(32), salesChannelId: "c".repeat(32), visibility: 30 }]);
});

Case studies

Shopware 5 migration

A furniture store migrated four thousand products and half of them never appeared

A furniture retailer moved from Shopware 5 to Shopware 6 with a third-party migration tool that copied products, categories, and sales channel assignments faithfully. Everything checked out in the Admin: active products, correct categories, correct channel. But roughly half the catalog never showed up on the new storefront, and the migration vendor could not immediately explain why since nothing in their logs mentioned an error.

Running the reconciler in dry run against the production sales channel id found that every affected product had zero rows in product_visibility, while the working half all had one. The migration tool had only been copying the sales channel assignment, not the separate visibility entity. Applying the fix created the missing rows in one pass, and the full catalog appeared on the storefront within minutes.

Retried sync batch

New products from a nightly PIM sync intermittently went invisible

A store synced its catalog nightly from a product information management system through Shopware's sync API. A subset of new products, usually the ones in a batch that got retried after a timeout, would import successfully and show as active, but never appeared in that day's storefront listing. The pattern looked random until someone checked product_visibility directly.

The retried batches were hitting the sync API's known behavior of skipping visibility creation on anything but a true first-time insert. The team added the reconciler as a step that runs right after the nightly sync, in dry run first to confirm the count matched the known gap, then for real. New products from a retried batch now get their missing row filled in automatically before customers ever see the gap.

What good looks like

After this runs on a schedule or right after every import job, a product that is active, in stock, and assigned to a channel actually shows up there. The script never guesses at intent: it only ever creates a visibility row for a product already marked active, and it re-checks right before writing so a retried run never collides with a row someone else just created. The Admin UI keeps looking exactly the same, since it was already reading the same table, but the storefront finally agrees with what the catalog owner expects to see.

FAQ

Why does a fully configured Shopware 6 product not show up in the storefront?

Storefront visibility in Shopware 6 is not implied by category or sales channel assignment alone. It requires a separate row in product_visibility with the product id, the sales channel id, and a visibility value. The Storefront and Store API listing routes filter on that row, so a product that is active, in stock, and assigned to a channel still returns nothing if the row was never created.

Is it safe to auto-create missing product_visibility rows?

Yes, when the script only inserts rows for products that are already active and only for sales channels you explicitly required, and re-checks that the row is still absent immediately before writing it. Never create a visibility row for a disabled or draft product, since that would surface it before it is ready.

Why does Shopware's sync API skip creating visibilities on a later sync?

Shopware's sync/upsert action has historically only written product_visibility rows on the first-time insert of a product. On a later sync of the same product, whether it is a re-import, a retried batch, or an update from a third-party connector, the visibilities can be skipped or the call can error if it tries to insert a row that already exists, since productId and salesChannelId form a composite primary key.

Related field notes

Citations

On the problem:

  1. Products not shown in Storefront. github.com/shopware/shopware/issues/3268
  2. Syncapi fatal error if visibilities already present. github.com/shopware/shopware/issues/595
  3. How does product visibility work in Shopware 6? matheusgontijo.com/2022/01/25/how-does-product-visibility-work-in-shopware-6

On the solution:

  1. Shopware Admin API: the ProductVisibility entity. shopware.stoplight.io/docs/admin-api/8b3fa9c2e325c-product-visibility
  2. Shopware Developer Docs: Search Criteria. developer.shopware.com/docs/guides/development/integrations-api/search-criteria.html
  3. Shopware Developer Docs: Filters Reference. developer.shopware.com/docs/guides/development/troubleshooting/dal-reference/filters-reference.html

Stuck on a tricky one?

If you have a problem in Shopware order states, stock, message queue, or data integrity 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 bring a product back to life?

If this saved you a confusing missing-product ticket or a wasted afternoon in the Admin, 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 Shopware field notes