Policy hooks
The event catalogue is what Stockly reports. This page is the other direction: the few places where integrating code decides and the module obeys.
Three shapes, in increasing weight: refuse an operation, re-order a decision, add a capability.
Refusing an operation
Section titled “Refusing an operation”BeforeStockOperationEvent is dispatched before every manual add, remove or move, outside the
transaction, so a listener may do its own writes without being drawn into (or replayed with)
Stockly’s unit of work. It is dispatched under its class name, not a dotted one.
public static function getSubscribedEvents(): array{ return [BeforeStockOperationEvent::class => 'onBeforeStockOperation'];}What it provides:
| Getter | |
|---|---|
getProductId() | the article |
getOperation() | add, remove or move |
getQuantity() | always positive |
getWarehouseId(), getBinLocationId() | for an arrival, where the goods land; otherwise the source |
getTargetWarehouseId(), getTargetBinLocationId() | the far end of a move |
getMovementType() | the reason the caller is about to record |
getOrderId(), getOperationId() | the document behind it, when there is one |
getContext() |
Two ways to refuse, both permanently supported:
// The original contract: throw anything, and it reaches the caller untouched.throw new MyPluginException('not allowed here');
// The softer path: a reason code the front end shows as "not allowed" rather than as a crash.$event->deny('DERIVED_STOCK', 'This product carries no stock of its own.');deny() becomes one uniform P2LAB_STOCKLY__STOCK__OPERATION_DENIED (HTTP 409) carrying the
supplied code and message. A thrown exception passes straight through, because plugins depend on the exact type
they throw. The first refusal wins; later ones are ignored.
The canonical use is a cooperating plugin refusing physical goods on a product whose stock is derived from another one and must stay at zero.
Re-ordering a decision
Section titled “Re-ordering a decision”Four decisions are offered for override. Each is dispatched synchronously, inside the decision, and carries the answer Stockly reached on its own:
| Event | Decides | Default | May you answer with nothing? |
|---|---|---|---|
p2lab_stockly.resolve_backorder_priority | which waiting lines get arriving stock, and in what order | oldest demand first | yes |
p2lab_stockly.resolve_sourcing_warehouses | which warehouses may serve a demand, and in what order | the channel’s assignment, by priority | no — demand has to live somewhere |
p2lab_stockly.resolve_pick_locations | which bins a picker is sent to | earliest expiry, then the fuller shelf | yes — it means “none of these” |
p2lab_stockly.resolve_putaway_location | where arriving goods are put away | Stockly’s own bin scoring | yes — it means “nowhere suitable” |
Read getResolved(), answer with setResolved():
public function onResolve(object $event): void{ if (!method_exists($event, 'getResolved')) { return; }
$lines = $event->getResolved(); usort($lines, fn ($a, $b) => $this->tierOf($b['orderId']) <=> $this->tierOf($a['orderId'])); $event->setResolved($lines);}Not answering at all is a supported state, not a degraded one: the default is exactly what Stockly would have decided alone, so “no listener” and “no integration” are the same thing and nothing has to be configured to keep working.
Three rules bind every policy listener.
The answer is validated and rejected whole. An entry may be dropped, in which case it keeps waiting, but none may be invented or repeated. An invalid answer is logged, and Stockly falls back to its own decision; it is never partially applied, because a half-applied priority mis-routes physical goods.
Do not call back into Stockly. No StockService, no reconciliation. The listener is inside the
decision, usually inside a transaction.
No side effects. Writes belong on a fact listener, after the commit, which is also where they are safe: a policy listener’s exception propagates rather than being swallowed, because influencing the outcome is the entire purpose.
Adding a capability
Section titled “Adding a capability”Interfaces to implement, registered with a service tag. These add something the module does not ship rather than changing something it does.
| Interface | Adds |
|---|---|
ParcelSplitStrategyInterface | a way of laying goods across boxes |
CarrierProfileInterface | a carrier: its limits, its products, its validations |
CarrierManifestGatewayInterface | telling a carrier that a hand-over closed |
ExtractionEngineInterface | a document-reading engine for PDF import |
The shipping half also takes hooks by name rather than by interface:
p2lab.stockly.parcels.plan, p2lab.stockly.label.print and their siblings. Those are described in
Integrating with Stockly.
Choosing between them
Section titled “Choosing between them”| You want to | Use |
|---|---|
| stop something from happening | BeforeStockOperationEvent |
| change which of several valid outcomes is chosen | the matching resolve_… event |
| react after the fact | a fact from the catalogue |
| do something the module cannot do at all | an interface above |
The most common error is using a fact where a policy belongs. By the time a fact is published the goods have moved and the transaction is closed; there is nothing left to influence, only something to record.
- Events — the facts, and what they guarantee
- Putting stock in a bin — what the veto sits in front of