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.
How to subscribe
Section titled “How to subscribe”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(), ]]);}The envelope
Section titled “The envelope”| Getter | Meaning |
|---|---|
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”| Spelling | Kind | You |
|---|---|---|
p2lab.stockly.… — a dot after the vendor | a hook | answer it |
p2lab_stockly.… — an underscore | a fact | observe 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”| Event | When | Key payload |
|---|---|---|
p2lab_stockly.demand.declared | a line’s demand is recorded for the first time | quantity, allocatedQuantity, shortfallQuantity, warehouseIds[], productId, orderedProductId |
p2lab_stockly.demand.changed | an existing line’s demand or coverage moved | the above plus previousQuantity, previousAllocated, previousShortfall |
p2lab_stockly.demand.deducted | goods physically left the shelf for the order | totalQuantity, lines[] with warehouseId / binLocationId / batchId / handlingUnitId |
p2lab_stockly.demand.released | the order stopped demanding | releasedQuantity, reasonCode (cancel / delete / edit), returnMode |
p2lab_stockly.sourcing.decided | a line was booked against a node, moved to another one, or could not be booked at all | chosenWarehouseId, 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.
Backorder — what is owed
Section titled “Backorder — what is owed”| Event | When | Key payload |
|---|---|---|
p2lab_stockly.backorder.opened | what a line is owed increased | shortfallQuantity (the new total), previousShortfall (0 = a genuine opening), totalOutstanding |
p2lab_stockly.backorder.covered | units were earmarked to a waiting line | coveredQuantity, remainingShortfall, triggerReason (receipt / transfer / release / correction) |
p2lab_stockly.backorder.closed | the line owes nothing any more | closedQuantity, 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.
Goods — what came in
Section titled “Goods — what came in”| Event | When | Key payload |
|---|---|---|
p2lab_stockly.goods.received | a purchase-order receipt was booked | purchaseOrderId, 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”| Event | When | Key payload |
|---|---|---|
p2lab_stockly.stock.moved | goods arrived, left or moved | movementType, quantity, delta, quantityAfter, from / to {warehouseId, binLocationId, lpCode}, batchId, expiresAt, orderId, purchaseOrderId, operationId |
p2lab_stockly.stock.availability_changed | what can be sold changed | previousAtp, 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”| Event | When | Key payload |
|---|---|---|
p2lab_stockly.work.pick_wave_planned | work was handed to a picker | the wave and its lines |
p2lab_stockly.work.short_pick_reported | a picker found a bin empty or short | expectedQuantity, foundQuantity, missingQuantity, reasonCode, stockCorrected, varianceHeld, reallocatedQuantity |
p2lab_stockly.work.short_pick_withdrawn | that report was taken back — the goods were there after all | expectedQuantity, missingQuantity, restoredQuantity, earmarkRestored |
p2lab_stockly.work.pick_line_completed | pieces went into the box | quantity, quantityPicked, quantityRequired, isComplete, pickLineId |
p2lab_stockly.work.stocktake_committed | a stock-check session was committed | lineCount, varianceLineCount, totalVarianceUnits, lines[], linesTruncated |
p2lab_stockly.work.quality_decided | a quarantined batch was passed or failed | decision, quantity, batchNumber, failAction, reason |
p2lab_stockly.work.transfer_state_changed | a transfer shipped, arrived, completed or was cancelled | fromState, 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.
Shipping — what left the building
Section titled “Shipping — what left the building”| Event | When | Key payload |
|---|---|---|
p2lab_stockly.shipping.shipment_booked | one box got a tracking code | orderNumber, parcelId, sequence, trackingCode, carrierProfile, weightKg, parcelCount, remainingParcels |
p2lab_stockly.shipping.order_shipped | every box of an order has a tracking code | orderNumber, parcelCount, trackingCodes[], trackingCodeList |
p2lab_stockly.shipping.shipment_failed | the carrier refused and the retries are exhausted | orderNumber, parcelId, sequence, carrierProfile, attempts, error |
p2lab_stockly.shipping.batch_handed_over | a driver took a set of boxes and signed for them | code, 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.
Returns — what came back
Section titled “Returns — what came back”| Event | When | Key payload |
|---|---|---|
p2lab_stockly.returns.announced | somebody raised a return; nothing has moved yet | returnNumber, orderId, type (withdrawal / complaint / cancellation), source (storefront / admin / system), contactEmail, requestedResolution, lineCount, totalQuantity, reasonCodes[] |
p2lab_stockly.returns.received | the goods arrived and were booked into the returns hold | returnNumber, warehouseId, binLocationId, receivedQuantity, announcedQuantity, outstandingQuantity, complete, lines[] |
p2lab_stockly.returns.dispositioned | somebody decided what happens to the units | returnNumber, lineId, productId, disposition (restock / scrap / hold), quantity, reasonCode, liability, remainingOnHold, documentComplete |
p2lab_stockly.returns.refunded | money went back to the customer | returnNumber, 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.
Integrity
Section titled “Integrity”| Event | When |
|---|---|
p2lab_stockly.stock_integrity_critical | a scan found more critical open findings than the configured threshold |
p2lab_stockly.external_stock_write_detected | somebody 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().
Subscribed by class name
Section titled “Subscribed by class name”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.
| Class | When |
|---|---|
P2Lab\Stockly\Event\StockChangedEvent | a cheap “this product changed, go re-read it” signal |
P2Lab\Stockly\Event\BeforeStockOperationEvent | before add, remove or move — the veto hook, described in policy hooks |
Guarantees, and what is not guaranteed
Section titled “Guarantees, and what is not guaranteed”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.
Flow Builder and App System webhooks
Section titled “Flow Builder and App System webhooks”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.
Compatibility
Section titled “Compatibility”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.
- Policy hooks — the events answered rather than observed
- Putting stock in a bin — what publishes the stock events