Skip to content

Events

Stockly publishes what happens in the warehouse so an integration does not have to poll its tables or, worse, write to product.stock, which the module owns and overwrites.

The catalogue below is grouped by plane. bin/console p2lab-stockly:events prints the live catalogue with listener counts, which is also the fastest way to confirm whether a listener is wired up.

Subscribe by event name, read the payload through duck-typed getters. No use of a Stockly class anywhere, no dependency in composer.json; the plugin then works with Stockly absent, with Stockly present, and across versions that moved a class.

public static function getSubscribedEvents(): array
{
return ['p2lab_stockly.backorder.opened' => 'onBackorderOpened'];
}
public function onBackorderOpened(object $event): void
{
if (!method_exists($event, 'getPayload')) {
return;
}
$data = $event->getPayload();
// $data['orderId'], $data['lineItemId'], $data['shortfallQuantity'], …
}

Every event carries the same envelope, so a generic bridge (webhook, queue, audit log) needs no knowledge of any concrete class and keeps working for events added later:

public function forward(object $event): void
{
if (!method_exists($event, 'getPayload') || !method_exists($event, 'getName')) {
return;
}
$this->http->post($this->url, ['json' => [
'event' => $event->getName(),
'id' => $event->getEventId(), // deduplication key
'at' => $event->getOccurredAt()->format(DATE_ATOM),
'data' => $event->getPayload(),
]]);
}
GetterMeaning
getName()stable public name, e.g. p2lab_stockly.demand.declared
getEventId()unique 32-character hex — use it to deduplicate
getSchemaVersion()raised only when a payload key is removed or changes meaning
getOccurredAt()a DateTimeImmutable
getCorrelationId()ties one unit of work together (order id, purchase order id)
getActor()['type' => user|storefront|cli|system, 'id' => ?string, 'name' => ?string]
getPayload()the whole fact, as JSON-serialisable scalars and arrays
getContext()the Shopware Context

Payloads are scalars, nulls and arrays of them, never entities, structs or DateTime objects; dates are ISO-8601 strings. Every id is 32-character lowercase hex without dashes, ready for UNHEX().

Two spellings, and they are not interchangeable

Section titled “Two spellings, and they are not interchangeable”
SpellingKindYou
p2lab.stockly.… — a dot after the vendora hookanswer it
p2lab_stockly.… — an underscorea factobserve it

The dotted names belong to the shipping and printing hooks: p2lab.stockly.parcels.plan, p2lab.stockly.label.print and their siblings, described in Integrating with Stockly.

Demand — what an order wants, and what covers it

Section titled “Demand — what an order wants, and what covers it”
EventWhenKey payload
p2lab_stockly.demand.declareda line’s demand is recorded for the first timequantity, allocatedQuantity, shortfallQuantity, warehouseIds[], productId, orderedProductId
p2lab_stockly.demand.changedan existing line’s demand or coverage movedthe above plus previousQuantity, previousAllocated, previousShortfall
p2lab_stockly.demand.deductedgoods physically left the shelf for the ordertotalQuantity, lines[] with warehouseId / binLocationId / batchId / handlingUnitId
p2lab_stockly.demand.releasedthe order stopped demandingreleasedQuantity, reasonCode (cancel / delete / edit), returnMode
p2lab_stockly.sourcing.decideda line was booked against a node, moved to another one, or could not be booked at allchosenWarehouseId, previousWarehouseId, rule, ruleSource, degraded, candidateCount, candidates[], triggerSource, requirement, settingsFingerprint

quantity = allocatedQuantity + shortfallQuantity always holds on declared and changed.

sourcing.decided fires only when the outcome is worth reporting: the node changed, the decision degraded, or the demand could not be booked. A repeated identical decision raises a counter on the allocation row instead, so a listener sees the history of the routing rather than the noise of order edits.

productId is the product that bears the stock; orderedProductId is the one on the order line. They differ when a cooperating plugin redirects stock, such as a bundle drawing from its components.

EventWhenKey payload
p2lab_stockly.backorder.openedwhat a line is owed increasedshortfallQuantity (the new total), previousShortfall (0 = a genuine opening), totalOutstanding
p2lab_stockly.backorder.coveredunits were earmarked to a waiting linecoveredQuantity, remainingShortfall, triggerReason (receipt / transfer / release / correction)
p2lab_stockly.backorder.closedthe line owes nothing any moreclosedQuantity, closeReason (fulfilled / cancelled)

covered fires for partial coverage too; remainingShortfall is the number that matters. A full cover is followed by closed.

A line whose allocation row is deleted outright produces no closed at all, because the row the shortage lived on went with it. Watch demand.released for that case.

EventWhenKey payload
p2lab_stockly.goods.receiveda purchase-order receipt was bookedpurchaseOrderId, supplierId, warehouseId, totalQuantity, items[] with quantity, bin, batch, expiry, license plate and landed cost

One event per receipt, not per line, because a receipt is one decision. The split is in items[].

Stock — what moved, and what it did to availability

Section titled “Stock — what moved, and what it did to availability”
EventWhenKey payload
p2lab_stockly.stock.movedgoods arrived, left or movedmovementType, quantity, delta, quantityAfter, from / to {warehouseId, binLocationId, lpCode}, batchId, expiresAt, orderId, purchaseOrderId, operationId
p2lab_stockly.stock.availability_changedwhat can be sold changedpreviousAtp, currentAtp, shelfTotal, shortfallTotal, isNegative, previousAvailable, currentAvailable

Direction lives in from / to: an arrival has from = null and a positive delta, a removal has to = null and a negative one, a move has both and delta = 0, because the warehouse total did not change.

Receiving, put-away, counting and palletising are all stock.moved with a different movementType. One fact, one event: filter on the type rather than looking for a separate event per operation.

availability_changed is deliberately separate. A reservation moves availability without any goods moving, and a bin-to-bin shuffle moves goods without touching availability. It is deduplicated per product per unit of work, so currentAtp is always the committed figure and never an intermediate one. It may be negative, and is published unclamped: three more sold than are held is an answer, not a corruption.

Work — what the people in the building did

Section titled “Work — what the people in the building did”
EventWhenKey payload
p2lab_stockly.work.pick_wave_plannedwork was handed to a pickerthe wave and its lines
p2lab_stockly.work.short_pick_reporteda picker found a bin empty or shortexpectedQuantity, foundQuantity, missingQuantity, reasonCode, stockCorrected, varianceHeld, reallocatedQuantity
p2lab_stockly.work.short_pick_withdrawnthat report was taken back — the goods were there after allexpectedQuantity, missingQuantity, restoredQuantity, earmarkRestored
p2lab_stockly.work.pick_line_completedpieces went into the boxquantity, quantityPicked, quantityRequired, isComplete, pickLineId
p2lab_stockly.work.stocktake_committeda stock-check session was committedlineCount, varianceLineCount, totalVarianceUnits, lines[], linesTruncated
p2lab_stockly.work.quality_decideda quarantined batch was passed or faileddecision, quantity, batchNumber, failAction, reason
p2lab_stockly.work.transfer_state_changeda transfer shipped, arrived, completed or was cancelledfromState, toState, sourceWarehouseId, targetWarehouseId, lines[]

short_pick_reported fires whether or not the variance gate corrected the shelf; the sighting is worth publishing either way. short_pick_withdrawn is its own event rather than a report with the numbers turned around: whoever acted on the shortage has to be told it was taken back, and restoredQuantity / earmarkRestored say what the compensation actually booked.

stocktake_committed fires even when nothing differed, because a clean count is the number accuracy is measured on. It caps lines[] at 500, largest variance first, and says so in linesTruncated.

pick_line_completed fires only for accepted scans; duplicates and conflicts publish nothing, because they credited nobody’s work.

EventWhenKey payload
p2lab_stockly.shipping.shipment_bookedone box got a tracking codeorderNumber, parcelId, sequence, trackingCode, carrierProfile, weightKg, parcelCount, remainingParcels
p2lab_stockly.shipping.order_shippedevery box of an order has a tracking codeorderNumber, parcelCount, trackingCodes[], trackingCodeList
p2lab_stockly.shipping.shipment_failedthe carrier refused and the retries are exhaustedorderNumber, parcelId, sequence, carrierProfile, attempts, error
p2lab_stockly.shipping.batch_handed_overa driver took a set of boxes and signed for themcode, carrierProfile, warehouseId, shipDate, parcelCount, orderCount, manifestExternalId, closedByName

remainingParcels is what makes a partial shipment expressible: Shopware’s delivery state machine has “shipped partially”, and without the counter a Flow could only ever say “shipped”, which is untrue until the last box is booked.

order_shipped may fire more than once for the same order, by design. Adding a parcel to an already-shipped order and booking it satisfies the condition again, and that is a second shipment rather than a fault. A Flow reacting to it should force the state transition, or the second run fails on an order already in the target state.

batch_handed_over deliberately carries no single order, because a batch spans as many orders as fitted on the trolley. Per-order automation belongs on the other two.

EventWhenKey payload
p2lab_stockly.returns.announcedsomebody raised a return; nothing has moved yetreturnNumber, orderId, type (withdrawal / complaint / cancellation), source (storefront / admin / system), contactEmail, requestedResolution, lineCount, totalQuantity, reasonCodes[]
p2lab_stockly.returns.receivedthe goods arrived and were booked into the returns holdreturnNumber, warehouseId, binLocationId, receivedQuantity, announcedQuantity, outstandingQuantity, complete, lines[]
p2lab_stockly.returns.dispositionedsomebody decided what happens to the unitsreturnNumber, lineId, productId, disposition (restock / scrap / hold), quantity, reasonCode, liability, remainingOnHold, documentComplete
p2lab_stockly.returns.refundedmoney went back to the customerreturnNumber, orderId, amount (this payment; negative on a chargeback), refundedTotal, refundState, source (manual / payment), comment

returns.refunded is not implied by any of the other three. A claim is routinely received, decided and closed while the transfer is still to be made, so the refund is tracked on its own status axis. It fires once per payment, so a claim settled in two instalments sends it twice: amount is that instalment, refundedTotal is where the claim stands afterwards.

source and type are on the payload because three different paths raise the same document: the storefront wizard, an operator taking a phone call, and the cancellation path with no human at all. A Flow that mails “we received your return request” wants the first and not the other two.

announcedQuantity travels next to receivedQuantity on purpose: the two disagreeing is the ordinary case, and usually the thing worth acting on.

EventWhen
p2lab_stockly.stock_integrity_criticala scan found more critical open findings than the configured threshold
p2lab_stockly.external_stock_write_detectedsomebody wrote product.stock from outside and the guard recorded it

external_stock_write_detected carries productId, expectedValue (what Stockly held), foundValue (what the writer left behind), delta, source (dal for an API or DAL write, sql for one made straight in the database) and policyApplied (off / correct / restore). One dispatch per product per unit of work, after the commit. See external writes.

stock_integrity_critical is written for the Flow Builder and carries scalar values rather than the envelope; every other event on this page answers getPayload().

Two events predate the naming scheme and are dispatched under their fully qualified class name. That will not change, because plugins are already subscribed to them.

ClassWhen
P2Lab\Stockly\Event\StockChangedEventa cheap “this product changed, go re-read it” signal
P2Lab\Stockly\Event\BeforeStockOperationEventbefore add, remove or move — the veto hook, described in policy hooks

Facts are published after the commit. What an event describes has already happened and will not be rolled back afterwards.

A failing listener cannot break the warehouse. Exceptions from fact listeners are logged and swallowed. Symfony has no per-listener isolation, though, so a throwing listener does cut off the ones queued behind it. Policy listeners are the opposite: their exceptions propagate, because influencing the outcome is the point.

There is no delivery guarantee. Events are in-process function calls. If the process dies between the commit and the dispatch, the change is in the database and the event reached nobody, and it will not be replayed later, because the reconciliation is idempotent and the next pass sees nothing to do. An integration that must not diverge needs a periodic reconciliation of its own, not just the events.

There is no ordering guarantee between planes. In one unit of work a backorder event and a demand event may arrive in either order. getCorrelationId() plus getOccurredAt() allows them to be grouped and sorted.

A listener is not in Stockly’s transaction. Rolling a listener’s writes back does not roll Stockly’s back.

Nothing is published on read. Reports and listings emit no events.

Twenty-three events are offered as Flow Builder triggers, so a merchant can wire them to a mail, a webhook or a tag without anybody writing code: every event above except the five that fire per line, per movement or per scan, namely demand.declared, demand.changed, sourcing.decided, stock.moved and work.pick_line_completed.

The same events reach App System webhooks. Shopware treats any Flow-aware event as hookable, so an app can subscribe and delivery goes through core’s own webhook log and message queue, with retry and cleanup. For an external system that is the reliable path, and it needs nothing from this plugin.

Two rules govern the list, and both exist to keep it honest.

A lifecycle is offered whole. opened / covered / closed travel together, and deducted travels with released. Half a lifecycle in the Flow Builder is worse than none: it lets a merchant build an automation that tells a customer their goods are on the way after they asked for a refund.

High-frequency events stay out. The five above fire per order line, per movement and per scan. Offering them in the UI ends with an automation that mails a thousand times a day. Anyone who genuinely needs them writes a plugin and subscribes by name, where there is no such limit. stock.availability_changed is on the list despite living on a busy plane, and the deduplication is why: it fires when availability actually moved, not when anything happened.

Event names never change. Constructors only gain trailing optional parameters. getPayload() may gain keys without notice; a key is only removed or redefined with a getSchemaVersion() raise.