Appearance
Architecture & reference
For developers
| Plugin class | Kommandhub\WarehouseStockSW\KmhWarehouseStockSW (technical name KmhWarehouseStockSW) |
| Namespace | Kommandhub\WarehouseStockSW\ → src/ (PSR-4) |
| Package | kommandhub/warehouse-stock-sw |
| Requires | shopware/core and shopware/storefront ~6.7.0, PHP 8.2+ |
| Repository | github.com/KommandHub-Org/KmhWarehouseStockSW |
How it works
- Stock is stored per warehouse and per product (variant) in
kmh_warehouse_stock. product.stockis derived:SUM(quantity)over active warehouses, written through the DAL. Core then mirrorsavailable_stock, re-indexesavailableand invalidates the product's cache tags. Core availability, closeout and listing filters stay untouched.- Core's
StockStorageis decorated. After core applies an order stock change, the decorator moves the same delta in the warehouses and records allocations inkmh_warehouse_allocation.
No CLI commands, scheduled tasks, custom events or Store API routes.
Modules
| Path | Responsibility |
|---|---|
Core/Content/Warehouse | Entity kmh_warehouse + translation kmh_warehouse_translation |
Core/Content/WarehouseStock | Entity kmh_warehouse_stock |
Core/Content/Product/ProductExtension | product.warehouseStocks (OneToMany, ApiAware, CascadeDelete; not auto-loaded) |
Stock/ProductStockRecalculator | Sets product.stock to the active-warehouse total |
Stock/WarehouseStockSubscriber | Triggers the recalculation on stock writes, warehouse deletes and active toggles |
Stock/WarehouseActiveChangedMessage + WarehouseActiveChangedHandler | Async recalculation after an active toggle |
Stock/WarehouseStockStorage | Decorates Shopware\Core\Content\Product\Stock\StockStorage |
Storefront/Subscriber/ProductPageSubscriber | Adds page.extensions.kmhWarehouseStock on the product detail page |
Storefront/StockLabel | Maps a quantity to inStock / lowStock / outOfStock |
Administration/Controller/WarehouseManagedController | GET /api/_action/kmh-warehouse-stock/managed/{productId} |
Setting/Service/Config | Sales-channel-aware reader for KmhWarehouseStockSW.config.* |
Logging/ConfigurableLogger | PSR-3 wrapper gated on the logging settings; channel kmh_warehouse_stock_channel |
Data model
kmh_warehouse — WarehouseDefinition
| Field | Type | Notes |
|---|---|---|
id | id | |
name | translated string, required | In kmh_warehouse_translation. |
code | string, required | Unique (uniq.kmh_warehouse.code). The ERP identifier. |
active | bool | Default true (DAL default and DB default 1). |
position | int | Default 0. Lower is used first. |
customFields | custom fields |
Association stocks → kmh_warehouse_stock (cascade delete).
kmh_warehouse_stock — WarehouseStockDefinition
| Field | Type | Notes |
|---|---|---|
id | id | |
warehouseId | FK → kmh_warehouse, required | ON DELETE CASCADE |
productId + productVersionId | FK → product, required | ON DELETE CASCADE |
quantity | int, required, min 0 | DB INT UNSIGNED, default 0 |
Unique key uniq.kmh_warehouse_stock.warehouse_product on (warehouse_id, product_id, product_version_id). Associations warehouse (ApiAware) and product.
kmh_warehouse_allocation — plain table, no DAL entity
| Column | Notes |
|---|---|
order_line_item_id, order_line_item_version_id | Always the live version. No FK: core restocks a deleted line item after the delete, and the allocation must still exist. |
product_id, warehouse_id | FK on warehouse_id → kmh_warehouse, ON DELETE CASCADE |
quantity | Units taken and not yet returned. Rows reaching 0 are deleted. |
Primary key: (order_line_item_id, order_line_item_version_id, product_id, warehouse_id).
Migrations: Migration1792700803CreateWarehouseTables, Migration1792900000CreateWarehouseAllocationTable.
Stock recalculation
ProductStockRecalculator::recalculate(array $productIds, Context $context):
- One query:
COALESCE(SUM(IF(w.active = 1, s.quantity, 0)), 0)per product on the live version,HAVING p.stock <> total. Only changed products are written. - Written with
product.repositoryinContext::SYSTEM_SCOPEon the live version. A caller with only warehouse privileges (an ERP integration) needs no product write permission. - A product with no stock rows left gets
0.
Triggers (WarehouseStockSubscriber):
| Event | Action |
|---|---|
kmh_warehouse_stock.written | Recalculate the affected products synchronously. Ignored outside the live version. |
kmh_warehouse.written with active in the payload | Dispatch WarehouseActiveChangedMessage(warehouseId) (AsyncMessageInterface). The handler recalculates every product with a row in that warehouse, 500 per batch, in a CLI context. |
EntityDeleteEvent for kmh_warehouse_stock or kmh_warehouse | Resolve product ids before the delete; recalculate in addSuccess() after it. |
The recalculation writes only product, so it cannot recurse into these events.
Order stock: WarehouseStockStorage
Decorates Shopware\Core\Content\Product\Stock\StockStorage (AbstractStockStorage). load() and index() pass through. alter(list<StockAlteration>, Context):
text
decorated->alter() core adjusts product.stock
non-live version → return
transaction:
per change, delta = quantityDelta()
delta < 0 → take(-delta)
SELECT active rows ORDER BY w.position ASC, w.created_at ASC FOR UPDATE
take min(remaining, row) per warehouse, never below 0
upsert kmh_warehouse_allocation (+= taken)
delta > 0 → giveBack(delta)
SELECT allocations ORDER BY w.position DESC, w.created_at DESC FOR UPDATE
return min(remaining, allocated) per warehouse
delete allocations at 0
recalculate(changed product ids)
any Throwable → logger->error(...), swallowed- Products with no row in an active warehouse are left to core.
- A shortfall is not recorded, so it is never returned.
- Returns go to the allocation's warehouse whether it is active or not.
FOR UPDATEserialises concurrent orders on the same product's rows.StockAlteration::$lineItemIdis bound as binary whether core passes raw bytes (asOrderStockSubscriberdoes) or hex.- Exceptions are logged at
error, never rethrown: the order is already written, so throwing would only show the customer an error page.
Importing stock
There is no custom import endpoint. Use the Admin API sync endpoint (Shopware: Admin API):
http
POST /api/_action/sync
Authorization: Bearer <token>
Content-Type: application/json
[
{
"action": "upsert",
"entity": "kmh_warehouse",
"payload": [
{ "id": "0190a0000000700000000000000000a1", "code": "LAG", "name": "Lagos Main", "position": 1 }
]
},
{
"action": "upsert",
"entity": "kmh_warehouse_stock",
"payload": [
{ "id": "<stable id per warehouse+product>", "warehouseId": "0190a0000000700000000000000000a1", "productId": "<variant id>", "quantity": 20 }
]
}
]- Upserts match by
id. A newidfor an existing warehouse + product pair hits the unique key. Derive the row id deterministically, e.g.md5("LAG|SW-1001"), so repeated imports update the same row. - Stock is stored per variant id. Parent products are not aggregated.
- The integration needs
kmh_warehouse_stockread/create/update, pluskmh_warehouseread/create/update if it manages warehouses. It needs no product write permission. - The next import overwrites what orders took since the last one.
Standard entity routes also work, e.g. GET /api/kmh-warehouse, POST /api/search/kmh-warehouse-stock, DELETE /api/kmh-warehouse-stock/{id}.
HTTP endpoint
GET /api/_action/kmh-warehouse-stock/managed/{productId} — Admin API, ACL product:read.
json
{ "managed": true }true if the product has at least one kmh_warehouse_stock row (any warehouse, active or not) on the live version. An invalid id returns false. Used by the Administration to lock the stock field for users without kmh_warehouse_stock:read. It never returns quantities.
Storefront
ProductPageSubscriber listens to ProductPageLoadedEvent only; listings never load warehouse data.
availabilityDisplay = default→ nothing.visibilityRuleIdset and not inSalesChannelContext::getRuleIds()→ nothing.- Load the product's
kmh_warehouse_stockrows withwarehouse.active = true, sorted bywarehouse.position. None → nothing. - Add
page.extensions.kmhWarehouseStock(ArrayStruct):
json
{
"items": [
{ "name": "Lagos Main", "quantity": 3, "label": "lowStock" }
],
"replaceDefault": false
}name is the translated name, falling back to code. quantity is null in label mode. replaceDefault is true for availabilityDisplay = warehouse.
Template Resources/views/storefront/component/buy-widget/buy-widget.html.twig extends buy_widget_delivery_informations. It renders parent() unless replaceDefault is set, then block kmh_warehouse_stock. CSS hooks: .kmh-warehouse-stock, .kmh-warehouse-stock-item.is--inStock|is--lowStock|is--outOfStock. Override the kmh_warehouse_stock block in your theme to change the markup.
Administration
Source (src/Resources/app/administration/src/) | Does |
|---|---|
module/kmh-warehouse | Module kmh-warehouse: routes kmh.warehouse.list, .detail, .create; settings item in group shop |
component/kmh-product-warehouse-stock | The product card; parent with variants → variant grid + modal |
component/kmh-warehouse-stock-grid | One row per warehouse; creates kmh_warehouse_stock entities on first input |
extension/sw-product-detail | Adds warehouseStocks to the product criteria; after the parent saves, syncs edited variants in one request |
extension/sw-product-detail-base | Places the card in block sw_product_detail_base_category_card |
extension/sw-product-deliverability-form | Locks the stock field; calls the managed endpoint for users without kmh_warehouse_stock.viewer |
extension/sw-product-list | Column kmhWarehouseStocks after availableStock |
acl | Privileges kmh_warehouse.* and kmh_warehouse_stock.* under Catalogues |
The card loads up to 500 warehouses and 500 variants. Built output in src/Resources/public/ is generated; rebuild with bin/build-administration.sh after changing a source file.
ACL roles
| Key | Role | Privileges | Depends on |
|---|---|---|---|
kmh_warehouse | viewer | kmh_warehouse:read, kmh_warehouse_translation:read | — |
| editor | kmh_warehouse:update, kmh_warehouse_translation:create, kmh_warehouse_translation:update | viewer | |
| creator | kmh_warehouse:create | viewer, editor | |
| deleter | kmh_warehouse:delete | viewer | |
kmh_warehouse_stock | viewer | kmh_warehouse_stock:read, kmh_warehouse:read, kmh_warehouse_translation:read | product.viewer |
| editor | kmh_warehouse_stock:update | viewer, product.editor | |
| creator | kmh_warehouse_stock:create | viewer, editor | |
| deleter | kmh_warehouse_stock:delete | viewer |
Logging
Channel kmh_warehouse_stock_channel, rotating file %kernel.logs_dir%/kmh_warehouse_stock_%kernel.environment%.log, 7 files. ConfigurableLogger always writes emergency, alert, critical and error; lower levels only with enableDebugging and, if set, a matching logLevels entry. Both settings resolve against the sales channel passed as ConfigurableLogger::CONTEXT_SALES_CHANNEL_ID in the log context, else the global scope. The LoggerInterface binding is scoped to this plugin's services.
Lifecycle
| Step | Effect |
|---|---|
| Install / update | Migrations create the four tables. Nothing is seeded. |
| Deactivate | Decoration and subscribers are gone: order stock changes only touch product.stock. |
| Uninstall, keep data | Nothing removed. |
| Uninstall, no keep data | DROP TABLE kmh_warehouse_allocation, kmh_warehouse_stock, kmh_warehouse_translation, kmh_warehouse; migrations removed so a reinstall recreates them. product.stock keeps its last value. |
Development & testing
bash
git clone https://github.com/KommandHub-Org/KmhWarehouseStockSW.git
cd KmhWarehouseStockSW
make up # Shopware + this plugin in Docker
make shell # then: bin/console plugin:install --activate KmhWarehouseStockSW
make cs-fix && make analyse && make test # the gate to pass before every commit
make test FILTER=SomeTest
make test-coverage
make validate-plugin # Shopware Store compliance (shopware-cli)
make zip # release ZIP into build/tests/Unitmirrorssrc/and needs no kernel. Tests that boot Shopware carry#[Group('kernel')].- CI (GitHub Actions,
.github/workflows/php.yml): job quality runs composer validate, PHP lint, PHPStan level 9, php-cs-fixer and PHPUnit with a 100 % line coverage gate; job kernel-tests runs the kernel group against a real database, including order deduction and restocking. - Shared tooling: Development environment.