Reconciler Inventory and Stock
Stock and availableStock drift after direct writes bypass the stock API
A bulk import runs, an ERP sync pushes numbers straight into the database, or someone patches product.stock by hand outside the normal admin flow. The physical stock count updates, but the storefront keeps selling from an availableStock number that never moved. Nothing looks broken in the Administration, the product page just quietly oversells or undersells until someone notices the mismatch in a support ticket. Here is why Shopware leaves that second number behind and a small script that finds every drifted product and repairs it the way Shopware's own pipeline would.
Shopware 6 stores physical stock in product.stock, but availableStock (stock minus the quantities held by all open, unfinished order line items) is only recomputed by the StockUpdater subscriber inside ProductIndexer::update, and that recalculation runs only when a write touches the stock, isCloseout, or minPurchase fields through the normal DAL write and indexing pipeline. Bulk imports, ERP syncs, or direct database writes that set product.stock outside that pipeline never call updateAvailableStockAndSales, so availableStock is left holding a stale value. Run a small Python or Node.js script that independently computes the expected availableStock for every non-closeout product by summing open reservations, flags every mismatch, and repairs it by issuing a no-op PATCH to the stock field, which forces Shopware to recompute availableStock through its own indexing pipeline. Full code, tests, and sources are below.
The problem in plain words
Every product in Shopware 6 carries two stock numbers that look similar but mean different things. product.stock is the raw physical count, the number a warehouse system cares about. availableStock is what the storefront actually checks before letting someone add an item to the cart, and it equals stock minus whatever quantity is tied up in orders that have not finished yet.
Shopware keeps these numbers in sync with a subscriber called StockUpdater, which listens for writes to a product and recalculates availableStock as part of ProductIndexer::update. That subscriber only fires when the write goes through the Data Abstraction Layer and touches one of a small set of fields: stock, isCloseout, or minPurchase. A write that never passes through that pipeline, an import tool that disables indexing for speed, a direct SQL update against the database, or an ERP integration that pushes stock through its own channel, changes product.stock but never tells StockUpdater anything happened. The physical number moves. The number the storefront actually reads does not.
Why it happens
This comes down to Shopware's dual-field design and how narrowly the recalculation trigger is scoped. A few common ways stores end up here:
- A bulk import or ERP integration writes
product.stockin bulk, often through a path optimized for speed that disables indexing to avoid the cost of recalculating on every row. - A direct database write, whether a one-off SQL script or a migration, sets
stockwithout going through the DAL at all, so no subscriber has any chance to react. - The recalculation only triggers when the write payload touches
stock,isCloseout, orminPurchase. A write that changes other product fields, even ones related to inventory display, does not trip it. - Writes made while the checkout order route context flag suppresses the subscriber, a deliberate performance optimization during checkout, can also skip the recalculation in narrow windows.
- Shopware's own 2023 Stock Manipulation API ADR explicitly calls out this dual-field design and the expensive, fragile SUM-based recalculation as the reason it introduced
AbstractStockStorage::alter()as the single sanctioned mutation path, precisely so ad-hoc writes to stock can no longer silently desyncavailableStock.
availableStock is not a column you should ever hand-edit to match reality. It is a derived value, and the only trustworthy way to fix it is to make Shopware derive it again. Patching availableStock directly papers over one instance of the drift and leaves the next bypassed write free to desync it all over again. Patching stock instead, even with the same value it already has, forces StockUpdater and ProductIndexer to run their normal recalculation, which is the one place Shopware's own logic for counting open reservations lives.
The fix, as a flow
We never write to availableStock. The script authenticates, lists every non-closeout product, independently computes what availableStock should be by summing quantities from order line items on still-open orders, and compares that against the stored value. Anything that does not match gets a guarded, no-op PATCH to stock, which re-triggers Shopware's own indexing pipeline, and the script re-reads the product afterward to confirm the repair actually took.
Build it step by step
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.
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 DRY_RUN="true" # start safe, change to false to write
// 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 DRY_RUN="true" // start safe, change to false to write
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 product search, the reservation search, and the repair write.
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 {}
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) : {};
}
List every non-closeout product
Search for products where isCloseout is false, reading back id, productNumber, stock, and availableStock. Page with page and limit, and sort by id for a stable order across pages.
def find_stockable_products(token, page=1, limit=500):
body = {
"page": page,
"limit": limit,
"filter": [{"type": "equals", "field": "isCloseout", "value": False}],
"associations": {},
"sort": [{"field": "id", "order": "ASC"}],
"total-count-mode": 1,
}
return api("POST", "/api/search/product", token, body)
async function findStockableProducts(token, page = 1, limit = 500) {
const body = {
page,
limit,
filter: [{ type: "equals", field: "isCloseout", value: false }],
associations: {},
sort: [{ field: "id", order: "ASC" }],
"total-count-mode": 1,
};
return api("POST", "/api/search/product", token, body);
}
Sum open reservations for each product
For a given product id, search order-line-item for rows whose productId matches and whose associated order.stateMachineState.technicalName is open, then add up the quantity field. This mirrors exactly what availableStock is supposed to represent: stock minus everything still tied up in an unfinished order.
def sum_open_reserved_quantity(token, product_id):
body = {
"page": 1,
"limit": 500,
"filter": [
{"type": "equals", "field": "productId", "value": product_id},
{"type": "equals", "field": "order.stateMachineState.technicalName", "value": "open"},
],
"associations": {"order": {"associations": {"stateMachineState": {}}}},
"total-count-mode": 1,
}
data = api("POST", "/api/search/order-line-item", token, body)
return sum(row.get("quantity", 0) for row in data.get("data", []))
async function sumOpenReservedQuantity(token, productId) {
const body = {
page: 1,
limit: 500,
filter: [
{ type: "equals", field: "productId", value: productId },
{ type: "equals", field: "order.stateMachineState.technicalName", value: "open" },
],
associations: { order: { associations: { stateMachineState: {} } } },
"total-count-mode": 1,
};
const data = await api("POST", "/api/search/order-line-item", token, body);
return (data.data || []).reduce((sum, row) => sum + (row.quantity || 0), 0);
}
Decide, with two pure functions
Keep the decision logic in its own pure functions that take only primitive numbers and a boolean and return an answer. computeExpectedAvailableStock subtracts the open reserved quantity from stock and floors at zero, since availableStock should never go negative. isDrifted compares the stored value against that expectation. Neither function does any I/O, so both are trivial to unit test.
def compute_expected_available_stock(stock, open_reserved_quantity):
return max(stock - open_reserved_quantity, 0)
def is_drifted(available_stock, expected):
return available_stock != expected
export function computeExpectedAvailableStock(stock, openReservedQuantity) {
return Math.max(stock - openReservedQuantity, 0);
}
export function isDrifted(availableStock, expected) {
return availableStock !== expected;
}
Repair by re-triggering Shopware's own recalculation
Do not patch availableStock directly. Instead PATCH /api/product/{id} with {"stock": <same or corrected stock value>}. Even a no-op write to stock forces StockUpdater and ProductIndexer to recompute availableStock through the standard indexing pipeline. Guard the write behind DRY_RUN, and after a real write, re-fetch the product with GET /api/product/{id} to confirm availableStock now equals the independently computed expected value before calling the record reconciled.
def repair_stock_drift(token, product_id, current_stock):
api("PATCH", f"/api/product/{product_id}", token, {"stock": current_stock})
refreshed = api("GET", f"/api/product/{product_id}", token)
return refreshed["data"]["availableStock"]
async function repairStockDrift(token, productId, currentStock) {
await api("PATCH", `/api/product/${productId}`, token, { stock: currentStock });
const refreshed = await api("GET", `/api/product/${productId}`, token);
return refreshed.data.availableStock;
}
Always start with DRY_RUN=true, which only logs the intended {productId, currentStock, currentAvailableStock, expectedAvailableStock} pairs. When you switch DRY_RUN to false, the script re-fetches every product it patches and confirms availableStock now matches the expected value before treating it as reconciled, so you never walk away trusting a repair that silently failed.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever writes to stock, never to availableStock directly.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Find and repair Shopware 6 products whose availableStock has drifted from stock.
product.stock is the physical count. availableStock is stock minus quantities from all
open, unfinished order line items, and it is only recomputed by the StockUpdater
subscriber inside ProductIndexer::update, which only runs when a write touches the
stock, isCloseout, or minPurchase fields through the normal DAL write and indexing
pipeline. Bulk imports, ERP syncs, or direct database writes that set product.stock
outside that pipeline never trigger the recalculation, so availableStock is left stale.
This script never patches availableStock directly. It independently computes the
expected value by summing open reservations, flags any mismatch, and repairs it with a
no-op PATCH to stock, which forces Shopware's own recalculation to run. Run on a
schedule or on demand. Safe to run again and again.
"""
import os
import json
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_stock_drift")
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
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 find_stockable_products(token, page=1, limit=500):
body = {
"page": page,
"limit": limit,
"filter": [{"type": "equals", "field": "isCloseout", "value": False}],
"associations": {},
"sort": [{"field": "id", "order": "ASC"}],
"total-count-mode": 1,
}
return api("POST", "/api/search/product", token, body)
def sum_open_reserved_quantity(token, product_id):
body = {
"page": 1,
"limit": 500,
"filter": [
{"type": "equals", "field": "productId", "value": product_id},
{"type": "equals", "field": "order.stateMachineState.technicalName", "value": "open"},
],
"associations": {"order": {"associations": {"stateMachineState": {}}}},
"total-count-mode": 1,
}
data = api("POST", "/api/search/order-line-item", token, body)
return sum(row.get("quantity", 0) for row in data.get("data", []))
def compute_expected_available_stock(stock, open_reserved_quantity):
return max(stock - open_reserved_quantity, 0)
def is_drifted(available_stock, expected):
return available_stock != expected
def repair_stock_drift(token, product_id, current_stock):
api("PATCH", f"/api/product/{product_id}", token, {"stock": current_stock})
refreshed = api("GET", f"/api/product/{product_id}", token)
return refreshed["data"]["availableStock"]
def run():
token = get_token()
reconciled = []
page = 1
while True:
data = find_stockable_products(token, page=page)
rows = data.get("data", [])
if not rows:
break
for product in rows:
product_id = product["id"]
stock = product.get("stock", 0)
available_stock = product.get("availableStock", 0)
open_reserved_quantity = sum_open_reserved_quantity(token, product_id)
expected = compute_expected_available_stock(stock, open_reserved_quantity)
if not is_drifted(available_stock, expected):
continue
record = {
"productId": product_id,
"productNumber": product.get("productNumber"),
"currentStock": stock,
"currentAvailableStock": available_stock,
"expectedAvailableStock": expected,
}
log.warning(
"Product %s drifted. availableStock=%s expected=%s. %s",
record["productNumber"], available_stock, expected,
"dry run, would PATCH stock" if DRY_RUN else "repairing now",
)
if not DRY_RUN:
confirmed_available_stock = repair_stock_drift(token, product_id, stock)
record["confirmedAvailableStock"] = confirmed_available_stock
if confirmed_available_stock != expected:
log.error(
"Product %s still drifted after repair. got=%s expected=%s",
record["productNumber"], confirmed_available_stock, expected,
)
reconciled.append(record)
if page * 500 >= data.get("total", 0):
break
page += 1
log.info("Done. %d product(s) %s.", len(reconciled), "flagged" if DRY_RUN else "repaired")
print(json.dumps(reconciled, indent=2))
return reconciled
if __name__ == "__main__":
run()
/**
* Find and repair Shopware 6 products whose availableStock has drifted from stock.
*
* product.stock is the physical count. availableStock is stock minus quantities from all
* open, unfinished order line items, and it is only recomputed by the StockUpdater
* subscriber inside ProductIndexer::update, which only runs when a write touches the
* stock, isCloseout, or minPurchase fields through the normal DAL write and indexing
* pipeline. Bulk imports, ERP syncs, or direct database writes that set product.stock
* outside that pipeline never trigger the recalculation, so availableStock is left stale.
*
* This script never patches availableStock directly. It independently computes the
* expected value by summing open reservations, flags any mismatch, and repairs it with a
* no-op PATCH to stock, which forces Shopware's own recalculation to run. Run on a
* schedule or on demand. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/shopware/stock-drift-bypassing-stock-api/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function computeExpectedAvailableStock(stock, openReservedQuantity) {
return Math.max(stock - openReservedQuantity, 0);
}
export function isDrifted(availableStock, expected) {
return availableStock !== expected;
}
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 findStockableProducts(token, page = 1, limit = 500) {
const body = {
page,
limit,
filter: [{ type: "equals", field: "isCloseout", value: false }],
associations: {},
sort: [{ field: "id", order: "ASC" }],
"total-count-mode": 1,
};
return api("POST", "/api/search/product", token, body);
}
async function sumOpenReservedQuantity(token, productId) {
const body = {
page: 1,
limit: 500,
filter: [
{ type: "equals", field: "productId", value: productId },
{ type: "equals", field: "order.stateMachineState.technicalName", value: "open" },
],
associations: { order: { associations: { stateMachineState: {} } } },
"total-count-mode": 1,
};
const data = await api("POST", "/api/search/order-line-item", token, body);
return (data.data || []).reduce((sum, row) => sum + (row.quantity || 0), 0);
}
async function repairStockDrift(token, productId, currentStock) {
await api("PATCH", `/api/product/${productId}`, token, { stock: currentStock });
const refreshed = await api("GET", `/api/product/${productId}`, token);
return refreshed.data.availableStock;
}
export async function run() {
const token = await getToken();
const reconciled = [];
let page = 1;
while (true) {
const data = await findStockableProducts(token, page);
const rows = data.data || [];
if (rows.length === 0) break;
for (const product of rows) {
const productId = product.id;
const stock = product.stock || 0;
const availableStock = product.availableStock || 0;
const openReservedQuantity = await sumOpenReservedQuantity(token, productId);
const expected = computeExpectedAvailableStock(stock, openReservedQuantity);
if (!isDrifted(availableStock, expected)) continue;
const record = {
productId,
productNumber: product.productNumber,
currentStock: stock,
currentAvailableStock: availableStock,
expectedAvailableStock: expected,
};
console.warn(
`Product ${record.productNumber} drifted. availableStock=${availableStock} expected=${expected}. ${DRY_RUN ? "dry run, would PATCH stock" : "repairing now"}`
);
if (!DRY_RUN) {
const confirmedAvailableStock = await repairStockDrift(token, productId, stock);
record.confirmedAvailableStock = confirmedAvailableStock;
if (confirmedAvailableStock !== expected) {
console.error(
`Product ${record.productNumber} still drifted after repair. got=${confirmedAvailableStock} expected=${expected}`
);
}
}
reconciled.push(record);
}
if (page * 500 >= (data.total || 0)) break;
page++;
}
console.log(`Done. ${reconciled.length} product(s) ${DRY_RUN ? "flagged" : "repaired"}.`);
console.log(JSON.stringify(reconciled, null, 2));
return reconciled;
}
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 whether a product gets repaired or left alone. Because compute_expected_available_stock and is_drifted are pure, taking only primitive integers and a boolean, the tests need no network and no Shopware instance. They just feed in plain numbers and check the answer, covering the not-drifted case, the drifted case, and the clamp-at-zero case.
from reconcile_stock_drift import compute_expected_available_stock, is_drifted
def test_not_drifted_when_available_stock_matches_expected():
expected = compute_expected_available_stock(100, 30)
assert expected == 70
assert is_drifted(70, expected) is False
def test_drifted_when_available_stock_does_not_match_expected():
expected = compute_expected_available_stock(100, 45)
assert expected == 55
assert is_drifted(70, expected) is True
def test_expected_clamps_at_zero_when_reserved_exceeds_stock():
expected = compute_expected_available_stock(0, 10)
assert expected == 0
assert is_drifted(0, expected) is False
def test_expected_clamps_at_zero_with_positive_stock_but_larger_reservation():
expected = compute_expected_available_stock(5, 20)
assert expected == 0
def test_no_reservations_means_expected_equals_stock():
assert compute_expected_available_stock(42, 0) == 42
def test_is_drifted_true_when_available_stock_is_negative_but_expected_is_clamped():
expected = compute_expected_available_stock(0, 10)
assert is_drifted(-5, expected) is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { computeExpectedAvailableStock, isDrifted } from "./reconcile-stock-drift.js";
test("not drifted when availableStock matches expected", () => {
const expected = computeExpectedAvailableStock(100, 30);
assert.equal(expected, 70);
assert.equal(isDrifted(70, expected), false);
});
test("drifted when availableStock does not match expected", () => {
const expected = computeExpectedAvailableStock(100, 45);
assert.equal(expected, 55);
assert.equal(isDrifted(70, expected), true);
});
test("expected clamps at zero when reserved exceeds stock", () => {
const expected = computeExpectedAvailableStock(0, 10);
assert.equal(expected, 0);
assert.equal(isDrifted(0, expected), false);
});
test("expected clamps at zero with positive stock but larger reservation", () => {
assert.equal(computeExpectedAvailableStock(5, 20), 0);
});
test("no reservations means expected equals stock", () => {
assert.equal(computeExpectedAvailableStock(42, 0), 42);
});
test("is drifted true when availableStock is negative but expected is clamped", () => {
const expected = computeExpectedAvailableStock(0, 10);
assert.equal(isDrifted(-5, expected), true);
});
Case studies
An overnight ERP push oversold a bestseller
A furniture retailer synced warehouse counts from their ERP into Shopware every night through a bulk endpoint that wrote stock directly and skipped indexing to keep the run fast. The physical count updated correctly every time, but availableStock quietly fell behind whenever open orders changed between syncs. A popular chair sold out in the warehouse while the storefront still showed forty available, and a dozen orders came in for stock that no longer existed.
Running the reconciler nightly right after the ERP sync found the drifted products in minutes and repaired each one with a no-op patch to stock, forcing Shopware to recompute availableStock against the orders that had actually accumulated. Overselling on that bestseller stopped the same week.
A one-off SQL fix left stock quietly wrong for months
During a data cleanup after a platform migration, an engineer ran a direct SQL update to correct a batch of incorrect stock counts, reasoning it was a quick, safe fix since it only touched one column. The physical numbers were correct from that day forward, but availableStock on every one of those products stayed frozen at its pre-migration value, because the update never touched the DAL and StockUpdater never ran.
Months later, the reconciler's detection pass flagged the entire batch at once, each one showing the exact gap between the corrected physical stock and the stale availableStock left over from before the migration. A single dry run made the scope of the problem obvious, and the repair pass cleared all of it in one script run.
After this runs on a schedule, any bypassed write, whether from a bulk import, an ERP sync, or a one-off SQL fix, gets caught before it can quietly oversell or undersell a product. The script never touches availableStock directly, it only nudges stock to make Shopware run its own recalculation, and it confirms the repair actually landed before calling a product reconciled. Inventory numbers on the storefront stay honest without anyone having to remember which import tools are safe.
FAQ
Why do product.stock and availableStock fall out of sync in Shopware 6?
Shopware 6 keeps two separate numbers: product.stock, the physical count, and availableStock, stock minus quantities from all open order line items. The second number is only recomputed by the StockUpdater subscriber inside ProductIndexer, and that only runs when a write touches the stock, isCloseout, or minPurchase fields through the normal DAL write and indexing pipeline. Bulk imports, ERP syncs, or direct database writes that set product.stock outside that pipeline never trigger the recalculation, so availableStock is left holding a stale value from before the write.
Is it safe to fix drifted availableStock by patching it directly?
No. availableStock is being moved toward a write-protected, mirrored field as Shopware's Stock Manipulation API matures, so hand-editing it papers over one instance of the gap without fixing the pipeline that will drift it again on the next bypassed write. The safe repair is a no-op PATCH to the stock field itself, which forces Shopware's own StockUpdater and ProductIndexer to recompute availableStock the same way the standard indexing pipeline always would.
How do I detect availableStock drift without guessing?
For every non-closeout product, independently sum the quantities of order line items belonging to orders whose state machine technicalName is open, and compute expected availableStock as stock minus that sum, floored at zero. Compare that expected value against the stored availableStock. Any mismatch is drift, and the difference tells you exactly how far off the storefront's sellable quantity really is.
Related field notes
Citations
On the problem:
- Shopware Developer Documentation: Available stock improvements (ADR). developer.shopware.com available-stock ADR
- Shopware GitHub Discussions: Stock storage refactoring. github.com/shopware/shopware/discussions/3172
- Shopware GitHub Issues: Product availableStock doesn't update. github.com/shopware/shopware/issues/3449
On the solution:
- Shopware Developer Documentation: Stock Manipulation API (ADR). developer.shopware.com stock-api ADR
- Shopware Developer Documentation: Reading and Writing Stock. developer.shopware.com reading-writing-stock
- Shopware Developer Documentation: Stock Configuration. developer.shopware.com stock configuration
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.
Did this catch a drifted product?
If this saved you from an oversold bestseller or a support ticket about phantom stock, 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