Skip to content

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.

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.

Four decisions are offered for override. Each is dispatched synchronously, inside the decision, and carries the answer Stockly reached on its own:

EventDecidesDefaultMay you answer with nothing?
p2lab_stockly.resolve_backorder_prioritywhich waiting lines get arriving stock, and in what orderoldest demand firstyes
p2lab_stockly.resolve_sourcing_warehouseswhich warehouses may serve a demand, and in what orderthe channel’s assignment, by priorityno — demand has to live somewhere
p2lab_stockly.resolve_pick_locationswhich bins a picker is sent toearliest expiry, then the fuller shelfyes — it means “none of these”
p2lab_stockly.resolve_putaway_locationwhere arriving goods are put awayStockly’s own bin scoringyes — 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.

Interfaces to implement, registered with a service tag. These add something the module does not ship rather than changing something it does.

InterfaceAdds
ParcelSplitStrategyInterfacea way of laying goods across boxes
CarrierProfileInterfacea carrier: its limits, its products, its validations
CarrierManifestGatewayInterfacetelling a carrier that a hand-over closed
ExtractionEngineInterfacea 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.

You want toUse
stop something from happeningBeforeStockOperationEvent
change which of several valid outcomes is chosenthe matching resolve_… event
react after the facta fact from the catalogue
do something the module cannot do at allan 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.