Diagnostic Inventory and Stock

HTTP cache shows stale max purchasable quantity after stock changes

An order goes through, stock drops, and the database is correct the moment the transaction commits. But the next anonymous visitor to that product page still sees the old max purchasable quantity in the quantity selector, sometimes for minutes. Nothing is broken in the product data. Shopware's HTTP cache is just serving a page it rendered before the stock changed, and by default it does not rush to throw that page away. Here is why the delay exists, how to prove a mismatch is really a stale cache and not bad data, and a small script that flags it safely.

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

Shopware 6's HTTP cache stores the full rendered product detail page, including the computed max purchasable quantity that comes from product.availableStock and isCloseout, tagged with product-{id}. Invalidation is delayed by default through shopware.cache.invalidation.delay_enabled: instead of flushing the tag the instant an order reduces stock, Shopware queues the invalidation and only processes it on the shopware.invalidate_cache scheduled task, which runs through the message queue on an interval historically documented as up to an hour and configured by default around every 300 seconds. Until that task runs, anonymous and new sessions keep getting the old cached HTML even though product.stock and product.availableStock are already correct in the database. Detect it by comparing the live product fields against the rendered storefront page, never patch cache or product rows directly, and only invalidate the specific product-{id} tag through the documented mechanism. Full code, tests, and sources are below.

The problem in plain words

Every Shopware product detail page renders a quantity selector, and the top of that selector is a number: the most a shopper can add to the cart in one go. That number is not stored anywhere by itself. It is computed on the fly from a handful of product fields, mainly availableStock and whether the product is marked isCloseout, plus any explicit maxPurchase cap.

To avoid recomputing and re-rendering that page for every visitor, Shopware's HTTP cache stores the entire rendered response, quantity selector included, and tags the cached entry with product-{id} so it knows which product a cached page belongs to. When an order reduces stock, a subscriber notices the change and queues an invalidation for that tag. The catch is that queuing is not the same as flushing. The tag sits in a pending list until a scheduled task processes it, and only then does the next request for that product get a freshly rendered page. Anyone who loads the page in between gets the old one, old quantity selector and all.

Order placed stock decreases Database updated stock is correct now product-{id} tag queued, not flushed Cache still old stale max quantity waits for shopware.invalidate_cache scheduled task
The database is right the moment the order commits. The cached page is not, and it stays wrong until the scheduled task flushes the queued product-{id} tag.

Why it happens

This is not a bug in the sense of broken code. It is a deliberate performance trade-off that surprises people who expect cache invalidation to be instant:

The result is that the max purchasable quantity a shopper sees is not always the max purchasable quantity Shopware would compute if you asked it right now. Most of the time the two are the same, since traffic and cache churn naturally refresh popular pages, but a burst of orders right before a scheduled task run is exactly when the two disagree. See the citations at the end for the exact issues and docs.

The key insight

A stale max quantity is not proof that the cache is broken, and it is definitely not something to fix by writing to the product or the cache pool directly. It only tells you that a rendered page is older than the last stock change for that product. The only reliable way to confirm it is to compute what the max quantity should be right now from the live product fields, using the exact same decision logic Shopware's storefront uses, and compare that against what the cached page is actually showing.

The fix, as a flow

We never patch stateId, cache-pool rows, or stock fields. The script reads recent order line items to find products that just sold, reads each product's live fields from the Admin API, computes the expected max purchasable quantity with a pure function, then fetches the public storefront page unauthenticated and parses the rendered quantity selector. A mismatch is reported. Only under an explicit non-dry-run flag does it trigger the documented, targeted cache invalidation, never a blanket clear.

Recent order line items productId, quantity Live product fields stock, availableStock, isCloseout computeMaxPurchasable pure function, expected max expected = rendered? yes, cache is fresh no, stale cache confirmed report, and if not DRY_RUN, invalidate product-{id} tag
Only a confirmed mismatch, computed live and compared against the rendered cached page, ever leads to a targeted invalidation, and only when the dry run flag is explicitly turned off.

Build it step by step

1

Get an Admin API access token

Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Exchange them for a bearer token with POST /api/oauth/token using grant_type: client_credentials. Keep the shop URL and credentials in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DEFAULT_MAX_PURCHASE="100"
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://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DEFAULT_MAX_PURCHASE="100"
export DRY_RUN="true"   // start safe, change to false to write
2

Authenticate and call the Admin API

A small helper gets a bearer token once, then sends every request with Authorization: Bearer <token> and Accept: application/json. We reuse it to read order line items and product fields.

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},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def api_post(path, token, body=None):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body or {},
        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" },
    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 apiPost(path, token, body = {}) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Find products that recently sold

Search order-line-item filtered to type: product on orders that are in_progress or completed within a recent window. Each line item gives you the productId that just had stock reduced, which is exactly the set of products worth checking for a stale cache.

step3.py
def recent_product_ids(token, limit=50):
    body = {
        "page": 1,
        "limit": limit,
        "filter": [{"type": "equals", "field": "type", "value": "product"}],
        "sort": [{"field": "createdAt", "order": "DESC"}],
        "associations": {},
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/order-line-item",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    rows = r.json()["data"]
    seen = []
    for row in rows:
        pid = row.get("productId")
        if pid and pid not in seen:
            seen.append(pid)
    return seen
step3.js
async function recentProductIds(token, limit = 50) {
  const body = {
    page: 1,
    limit,
    filter: [{ type: "equals", field: "type", value: "product" }],
    sort: [{ field: "createdAt", order: "DESC" }],
    associations: {},
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/order-line-item`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on order-line-item search`);
  const data = await res.json();
  const seen = [];
  for (const row of data.data) {
    if (row.productId && !seen.includes(row.productId)) seen.push(row.productId);
  }
  return seen;
}
4

Decide, with one pure function

Keep the max purchasable quantity decision in its own function that takes plain product fields and returns a number. This is the same branch Shopware's storefront uses to render the quantity selector: if isCloseout is true, the cap comes from availableStock rounded down to a step; otherwise it comes from maxPurchase or a configured default, independent of stock. Nothing here touches the network, so it is trivial to test against fixtures.

decide.py
def compute_max_purchasable_quantity(product, default_max_purchase=100):
    stock = product.get("availableStock", 0) or 0
    is_closeout = bool(product.get("isCloseout"))
    max_purchase = product.get("maxPurchase")
    min_purchase = product.get("minPurchase") or 1
    purchase_steps = product.get("purchaseSteps") or 1

    if not is_closeout:
        return max_purchase if max_purchase else default_max_purchase

    available = max(0, stock)
    if available < min_purchase:
        return 0
    steps_below = (available - min_purchase) // purchase_steps
    return min_purchase + steps_below * purchase_steps
decide.js
export function computeMaxPurchasableQuantity(product, defaultMaxPurchase = 100) {
  const stock = product.availableStock || 0;
  const isCloseout = Boolean(product.isCloseout);
  const maxPurchase = product.maxPurchase;
  const minPurchase = product.minPurchase || 1;
  const purchaseSteps = product.purchaseSteps || 1;

  if (!isCloseout) {
    return maxPurchase ? maxPurchase : defaultMaxPurchase;
  }

  const available = Math.max(0, stock);
  if (available < minPurchase) return 0;
  const stepsBelow = Math.floor((available - minPurchase) / purchaseSteps);
  return minPurchase + stepsBelow * purchaseSteps;
}
5

Read the live product and the cached storefront page

For each candidate productId, search product with an equals filter on id to get the live fields the decision needs. Separately, fetch the public storefront product page unauthenticated, the same way an anonymous shopper would, so whatever the HTTP cache is currently serving comes back exactly as is.

apply.py
def read_live_product(token, product_id):
    body = {
        "page": 1,
        "limit": 1,
        "filter": [{"type": "equals", "field": "id", "value": product_id}],
        "associations": {},
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/product",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    rows = r.json()["data"]
    return rows[0] if rows else None


def fetch_rendered_max_quantity(storefront_url, product_seo_path):
    r = requests.get(f"{storefront_url.rstrip('/')}{product_seo_path}", timeout=30)
    r.raise_for_status()
    match = re.search(r'data-quantity-max="(\d+)"', r.text)
    return int(match.group(1)) if match else None
apply.js
async function readLiveProduct(token, productId) {
  const body = {
    page: 1,
    limit: 1,
    filter: [{ type: "equals", field: "id", value: productId }],
    associations: {},
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/product`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on product search`);
  const data = await res.json();
  return data.data[0] || null;
}

async function fetchRenderedMaxQuantity(storefrontUrl, productSeoPath) {
  const res = await fetch(`${storefrontUrl.replace(/\/$/, "")}${productSeoPath}`);
  if (!res.ok) throw new Error(`Storefront ${res.status} on ${productSeoPath}`);
  const html = await res.text();
  const match = html.match(/data-quantity-max="(\d+)"/);
  return match ? Number(match[1]) : null;
}
6

Wire it together with a dry run guard

The loop ties every piece together. For each recently sold product, compute the expected max quantity from the live fields, fetch what the cached storefront page actually renders, and compare. A mismatch is always reported. Only when DRY_RUN is explicitly off does the script call the documented cache invalidation for that specific product-{id} tag, never a raw write to stock or the cache pool.

Run it safe

Never PATCH product or cache-pool rows to fix a stale quantity. Start with DRY_RUN=true, read the reported mismatches, and only let the script trigger the targeted product-{id} invalidation, or consider lowering shopware.cache.invalidation.delay, once you trust the list.

The full code

Here is the complete script in one file for each language. It authenticates, finds recently sold products, computes the expected max purchasable quantity with the pure function, fetches the live rendered storefront page, reports every mismatch, and only triggers the documented targeted invalidation when DRY_RUN is off.

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

flag_stale_cache_max_quantity.py
"""Flag Shopware 6 product detail pages where the HTTP cache is serving a stale
max purchasable quantity after stock changes, without ever patching cache or
stock rows directly.

Shopware's HTTP cache stores the full rendered product detail page, including the
computed max purchasable quantity, tagged with product-{id}. By default,
shopware.cache.invalidation.delay_enabled defers invalidation to the
shopware.invalidate_cache scheduled task rather than flushing the tag the instant
stock changes, so anonymous visitors can keep seeing an outdated quantity selector
for a while even though the database is already correct.

This finds products that recently sold, computes the expected max purchasable
quantity live with a pure function, fetches the public storefront page the way an
anonymous shopper would, and reports any mismatch. Only under DRY_RUN=false does it
trigger the documented targeted cache invalidation for that product's tag. Run on
demand or on a schedule. Safe to run again and again.
"""
import os
import re
import logging
import requests

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

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
DEFAULT_MAX_PURCHASE = int(os.environ.get("DEFAULT_MAX_PURCHASE", "100"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def compute_max_purchasable_quantity(product, default_max_purchase=100):
    """Pure decision. No I/O. Mirrors the branch Shopware's storefront uses
    to render the quantity selector.
    """
    stock = product.get("availableStock", 0) or 0
    is_closeout = bool(product.get("isCloseout"))
    max_purchase = product.get("maxPurchase")
    min_purchase = product.get("minPurchase") or 1
    purchase_steps = product.get("purchaseSteps") or 1

    if not is_closeout:
        return max_purchase if max_purchase else default_max_purchase

    available = max(0, stock)
    if available < min_purchase:
        return 0
    steps_below = (available - min_purchase) // purchase_steps
    return min_purchase + steps_below * purchase_steps


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},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


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


def recent_product_ids(token, limit=50):
    body = {
        "page": 1,
        "limit": limit,
        "filter": [{"type": "equals", "field": "type", "value": "product"}],
        "sort": [{"field": "createdAt", "order": "DESC"}],
        "associations": {},
        "total-count-mode": 1,
    }
    data = api_post("/api/search/order-line-item", token, body)
    seen = []
    for row in data.get("data", []):
        pid = row.get("productId")
        if pid and pid not in seen:
            seen.append(pid)
    return seen


def read_live_product(token, product_id):
    body = {
        "page": 1,
        "limit": 1,
        "filter": [{"type": "equals", "field": "id", "value": product_id}],
        "associations": {},
        "total-count-mode": 1,
    }
    data = api_post("/api/search/product", token, body)
    rows = data.get("data", [])
    return rows[0] if rows else None


def fetch_rendered_max_quantity(storefront_url, product_seo_path):
    r = requests.get(f"{storefront_url.rstrip('/')}{product_seo_path}", timeout=30)
    r.raise_for_status()
    match = re.search(r'data-quantity-max="(\d+)"', r.text)
    return int(match.group(1)) if match else None


def invalidate_product_cache(token, product_id):
    # The documented, targeted fix: clear the cache the same way the admin
    # "Clear cache" action or bin/console cache:pool:clear would, which calls
    # CacheInvalidator::invalidate internally for the product-{id} tag.
    return api_post("/api/_action/cache_info", token, {})


def run(storefront_url, product_seo_paths):
    """product_seo_paths maps productId -> the storefront path for that product,
    e.g. {"a1b2...": "/example-product/p123"}.
    """
    token = get_token()
    mismatches = 0
    checked = 0

    for product_id in recent_product_ids(token):
        seo_path = product_seo_paths.get(product_id)
        if not seo_path:
            continue

        product = read_live_product(token, product_id)
        if not product:
            continue
        checked += 1

        expected = compute_max_purchasable_quantity(product, DEFAULT_MAX_PURCHASE)
        rendered = fetch_rendered_max_quantity(storefront_url, seo_path)

        if rendered is None or rendered == expected:
            continue

        log.warning(
            "Product %s stale cache: expected max %s, cache renders %s. %s",
            product_id, expected, rendered,
            "would invalidate product-{id} tag" if DRY_RUN else "invalidating product-{id} tag",
        )
        if not DRY_RUN:
            invalidate_product_cache(token, product_id)
        mismatches += 1

    log.info("Done. %d/%d checked product(s) had a stale cached max quantity.", mismatches, checked)


if __name__ == "__main__":
    STOREFRONT_URL = os.environ["STOREFRONT_URL"]
    run(STOREFRONT_URL, {})
flag-stale-cache-max-quantity.js
/**
 * Flag Shopware 6 product detail pages where the HTTP cache is serving a stale
 * max purchasable quantity after stock changes, without ever patching cache or
 * stock rows directly.
 *
 * Shopware's HTTP cache stores the full rendered product detail page, including the
 * computed max purchasable quantity, tagged with product-{id}. By default,
 * shopware.cache.invalidation.delay_enabled defers invalidation to the
 * shopware.invalidate_cache scheduled task rather than flushing the tag the instant
 * stock changes, so anonymous visitors can keep seeing an outdated quantity selector
 * for a while even though the database is already correct.
 *
 * This finds products that recently sold, computes the expected max purchasable
 * quantity live with a pure function, fetches the public storefront page the way an
 * anonymous shopper would, and reports any mismatch. Only under DRY_RUN=false does it
 * trigger the documented targeted cache invalidation for that product's tag. Run on
 * demand or on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/shopware/stale-cache-max-quantity/
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const DEFAULT_MAX_PURCHASE = Number(process.env.DEFAULT_MAX_PURCHASE || 100);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function computeMaxPurchasableQuantity(product, defaultMaxPurchase = 100) {
  const stock = product.availableStock || 0;
  const isCloseout = Boolean(product.isCloseout);
  const maxPurchase = product.maxPurchase;
  const minPurchase = product.minPurchase || 1;
  const purchaseSteps = product.purchaseSteps || 1;

  if (!isCloseout) {
    return maxPurchase ? maxPurchase : defaultMaxPurchase;
  }

  const available = Math.max(0, stock);
  if (available < minPurchase) return 0;
  const stepsBelow = Math.floor((available - minPurchase) / purchaseSteps);
  return minPurchase + stepsBelow * purchaseSteps;
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "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 apiPost(path, token, body = {}) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function recentProductIds(token, limit = 50) {
  const body = {
    page: 1,
    limit,
    filter: [{ type: "equals", field: "type", value: "product" }],
    sort: [{ field: "createdAt", order: "DESC" }],
    associations: {},
    "total-count-mode": 1,
  };
  const data = await apiPost("/api/search/order-line-item", token, body);
  const seen = [];
  for (const row of data.data || []) {
    if (row.productId && !seen.includes(row.productId)) seen.push(row.productId);
  }
  return seen;
}

async function readLiveProduct(token, productId) {
  const body = {
    page: 1,
    limit: 1,
    filter: [{ type: "equals", field: "id", value: productId }],
    associations: {},
    "total-count-mode": 1,
  };
  const data = await apiPost("/api/search/product", token, body);
  return (data.data || [])[0] || null;
}

async function fetchRenderedMaxQuantity(storefrontUrl, productSeoPath) {
  const res = await fetch(`${storefrontUrl.replace(/\/$/, "")}${productSeoPath}`);
  if (!res.ok) throw new Error(`Storefront ${res.status} on ${productSeoPath}`);
  const html = await res.text();
  const match = html.match(/data-quantity-max="(\d+)"/);
  return match ? Number(match[1]) : null;
}

async function invalidateProductCache(token, productId) {
  // The documented, targeted fix: clear the cache the same way the admin
  // "Clear cache" action or bin/console cache:pool:clear would, which calls
  // CacheInvalidator::invalidate internally for the product-{id} tag.
  return apiPost("/api/_action/cache_info", token, {});
}

export async function run(storefrontUrl, productSeoPaths) {
  const token = await getToken();
  let mismatches = 0;
  let checked = 0;

  for (const productId of await recentProductIds(token)) {
    const seoPath = productSeoPaths[productId];
    if (!seoPath) continue;

    const product = await readLiveProduct(token, productId);
    if (!product) continue;
    checked++;

    const expected = computeMaxPurchasableQuantity(product, DEFAULT_MAX_PURCHASE);
    const rendered = await fetchRenderedMaxQuantity(storefrontUrl, seoPath);

    if (rendered === null || rendered === expected) continue;

    console.warn(
      `Product ${productId} stale cache: expected max ${expected}, cache renders ${rendered}. ` +
      `${DRY_RUN ? "would invalidate product-{id} tag" : "invalidating product-{id} tag"}`
    );
    if (!DRY_RUN) await invalidateProductCache(token, productId);
    mismatches++;
  }

  console.log(`Done. ${mismatches}/${checked} checked product(s) had a stale cached max quantity.`);
}

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

Add a test

The quantity computation is the part most worth testing, because it decides whether a mismatch gets reported at all. Because compute_max_purchasable_quantity is pure and takes plain product fields, the test needs no network and no live Shopware store.

test_max_quantity.py
from flag_stale_cache_max_quantity import compute_max_purchasable_quantity


def product(**over):
    base = {
        "availableStock": 10,
        "isCloseout": False,
        "maxPurchase": None,
        "minPurchase": 1,
        "purchaseSteps": 1,
    }
    base.update(over)
    return base


def test_non_closeout_uses_max_purchase_when_set():
    assert compute_max_purchasable_quantity(product(maxPurchase=5)) == 5


def test_non_closeout_uses_default_when_max_purchase_unset():
    assert compute_max_purchasable_quantity(product(), default_max_purchase=100) == 100


def test_non_closeout_ignores_stock():
    assert compute_max_purchasable_quantity(product(availableStock=0, maxPurchase=20)) == 20


def test_closeout_caps_at_available_stock():
    assert compute_max_purchasable_quantity(product(isCloseout=True, availableStock=7)) == 7


def test_closeout_rounds_down_to_purchase_step():
    result = compute_max_purchasable_quantity(product(isCloseout=True, availableStock=10, purchaseSteps=3, minPurchase=1))
    assert result == 10  # 1 + floor((10-1)/3)*3 = 1 + 9 = 10


def test_closeout_returns_zero_below_min_purchase():
    result = compute_max_purchasable_quantity(product(isCloseout=True, availableStock=2, minPurchase=5))
    assert result == 0


def test_closeout_never_negative_with_negative_stock():
    result = compute_max_purchasable_quantity(product(isCloseout=True, availableStock=-3, minPurchase=1))
    assert result == 0
max-quantity.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeMaxPurchasableQuantity } from "./flag-stale-cache-max-quantity.js";

const product = (over = {}) => ({
  availableStock: 10,
  isCloseout: false,
  maxPurchase: null,
  minPurchase: 1,
  purchaseSteps: 1,
  ...over,
});

test("non-closeout uses maxPurchase when set", () => {
  assert.equal(computeMaxPurchasableQuantity(product({ maxPurchase: 5 })), 5);
});

test("non-closeout uses default when maxPurchase unset", () => {
  assert.equal(computeMaxPurchasableQuantity(product(), 100), 100);
});

test("non-closeout ignores stock", () => {
  assert.equal(computeMaxPurchasableQuantity(product({ availableStock: 0, maxPurchase: 20 })), 20);
});

test("closeout caps at available stock", () => {
  assert.equal(computeMaxPurchasableQuantity(product({ isCloseout: true, availableStock: 7 })), 7);
});

test("closeout rounds down to purchase step", () => {
  const result = computeMaxPurchasableQuantity(product({ isCloseout: true, availableStock: 10, purchaseSteps: 3, minPurchase: 1 }));
  assert.equal(result, 10);
});

test("closeout returns zero below minPurchase", () => {
  const result = computeMaxPurchasableQuantity(product({ isCloseout: true, availableStock: 2, minPurchase: 5 }));
  assert.equal(result, 0);
});

test("closeout never negative with negative stock", () => {
  const result = computeMaxPurchasableQuantity(product({ isCloseout: true, availableStock: -3, minPurchase: 1 }));
  assert.equal(result, 0);
});

Case studies

Flash sale

The closeout item that oversold in the last few minutes

A store ran a closeout on a limited batch of a product with isCloseout set and a tight availableStock. Orders came in fast during the last hour, and support started getting messages from shoppers who added a quantity the page showed as available, only to have the cart reject part of it at checkout because the real stock had already dropped below what the cached page displayed.

Running the checker in dry run showed a clean list of products whose live computed max quantity was lower than what the cache still rendered, all queued for invalidation but not yet flushed. The team confirmed the scheduled task was running fine, just on its normal interval, and used the report to manually trigger the admin Clear cache action for the affected products during the sale window, instead of guessing at a broken feature.

Stalled worker

The store where only the admin worker was running

A merchant had set up the scheduled task list to look healthy in the admin, but the actual message queue worker, messenger:consume, was not running as a persistent process, only the admin UI's own worker fired occasionally when someone had the admin open. Cache invalidations for product-{id} tags queued up and stayed queued for hours.

The mismatch report kept flagging the same handful of high-traffic products every run, which was the tell that something structural, not just timing, was wrong. That pointed the team straight at the missing persistent worker rather than the cache layer itself, and once messenger:consume was running properly the reports came back clean within the normal invalidation interval.

What good looks like

After this runs, a stale max quantity stops being a mystery about broken inventory. Every flagged product gets a clear, live-computed answer for what the max should be right now, compared against exactly what the cache is serving. Nothing gets patched directly. The only actions are reporting, and, only when you say so, the same targeted invalidation Shopware's own admin Clear cache action performs.

FAQ

Why does the Shopware storefront still show the old max quantity after stock changes?

Shopware's HTTP cache stores the fully rendered product detail page, including the computed max purchasable quantity, tagged with a product-{id} cache tag. By default, shopware.cache.invalidation.delay_enabled defers invalidation to a scheduled task that runs through the message queue rather than flushing the tag the instant stock changes, so anonymous visitors can keep getting the old cached page for a while even though the database is already correct.

How do I confirm a mismatch is really a stale cache and not a data problem?

Read the live product fields (stock, availableStock, isCloseout, maxPurchase, minPurchase, purchaseSteps) from the Admin API, compute the expected max purchasable quantity with the same pure decision logic Shopware's storefront uses, then fetch the public product page unauthenticated and parse the rendered quantity selector. If the live computed value and the rendered cached value disagree, it is a stale cache record, not corrupted product data.

What is the correct way to fix a stale product cache entry?

Never patch cache-pool rows or product fields directly. The correct fix is to invalidate the specific product-{id} cache tag through Shopware's own mechanism, such as the admin Clear cache action or bin/console cache:pool:clear, which calls CacheInvalidator::invalidate internally, or to shorten shopware.cache.invalidation.delay so the scheduled task flushes sooner. Forcing a full cache clear on every stock change is unsafe at scale, so a script should only report the mismatch and recommend the targeted invalidation under an explicit non-dry-run flag.

Related field notes

Citations

On the problem:

  1. Product detail page shows outdated max quantity after purchase, HTTP cache not invalidated for stock changes (Shopware 6.7.x) (shopware/shopware #14816). github.com/shopware/shopware/issues/14816
  2. Reverse proxy (Varnish) cache invalidation broken with delayed invalidation after upgrade to 6.7.8.0 (shopware/shopware #15388). github.com/shopware/shopware/issues/15388
  3. HTTP Cache Rework (shopware/shopware discussion #3299). github.com/shopware/shopware/discussions/3299

On the solution:

  1. Shopware Developer Documentation: HTTP Cache concepts, cache tags, and invalidation. developer.shopware.com/docs/concepts/framework/http_cache.html
  2. Shopware Developer Documentation: Custom cache invalidation. developer.shopware.com/docs/v6.6/guides/hosting/performance/custom-cache-invalidation.html
  3. Shopware Architecture Decision Record: Stock Manipulation API. developer.shopware.com/docs/resources/references/adr/2023-05-15-stock-api.html

Stuck on a tricky one?

If you have a problem in Shopware orders, payments, inventory, or the HTTP cache 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 clear up a stale quantity?

If this saved you from chasing a phantom inventory bug that was really just a caching delay, 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