Skip to main content

Stock events

Alfa Commerce tells plugins when stock is about to move and when it has moved. This page explains, in plain words first, what can happen to stock, then each event, then how a plugin can decide which location an order's stock comes from.

Read How events reach a plugin first. The words reservation, claim and stock movement are explained in the glossary.

What it does​

The five things that can happen to stock​

Stock of an item sits at a location (a warehouse, a shop). For an order, three things can happen to it:

In plain wordsThe name in the codeWhat it means
Set asideA reservation (reserve)The goods stay on the shelf, but they are kept for this order and no longer offered to others
Taken from stockA deduction (commitReservation)The goods leave the shelf for this order. The reservation becomes a stock movement.
Given backA release (release) or a restore (restore)A reservation is let go, or goods that were taken are put back on the shelf

Two more things happen without an order:

In plain wordsThe name in the codeWhat it means
A manual adjustmentadjustTo, adjust, receiveStaff correct the quantity of an item at a location
A transfertransferA quantity moves from one location to another
"Commit" has two meanings

In the stock code, "commit" means take the goods from stock (commitReservation, onInventoryBeforeCommit, onInventoryCommitted). Everywhere else in this manual "the commit" is the moment a save becomes final. This page says "taken from stock" for the first and "the save is final" for the second.

What decides which of them happens​

Every order status carries a claim: what an order in that status asks of the stock (StockPolicy::claimFor()).

The statusIts claimWhat Alfa does when an order enters it
A cancelled statusNothing (StockPolicy::CLAIM_NONE)Puts back what was taken and lets every reservation go
A status that keeps the goods in stockA reservation (StockPolicy::CLAIM_RESERVATION)Puts back what was taken and keeps the goods set aside
A status that removes the goods from stockThe goods themselves (StockPolicy::CLAIM_PHYSICAL)Takes the goods from stock

A document type can also move stock when a document of that type is issued. See Document events.

When it is used​

What a person doesWhat Alfa does with the stockIn the same save as the person's change?
A shopper or staff place an orderSets the lines aside. If the order's first status removes goods from stock, takes them at once.Yes. If the stock step fails, the order is not placed.
Staff or a payment plugin change an order's status to one with another claimMoves the stock to match the new claimNo. The status is saved first. The stock moves right afterwards, as background work.
Staff issue a document whose type moves stockTakes from stock or puts back, as the type saysNo. The document is saved first. The stock moves right afterwards.
Staff change the stock figure of an itemWrites one adjustmentIt is its own save

The second and third rows matter for a plugin. When a status change or a document owes a stock movement, Alfa records what is owed together with the change, then applies it. If applying it fails, the status or the document stays as saved, staff are told in the notification bell, and the background job stock.effects.drain tries again later (Tools > Database Sync applies it too). So a stock event can reach your plugin in a later request than the one in which staff pressed the button.

Planned for 2.2.1, which is not released: a movement that a stock plugin refuses on purpose (see The events that can refuse) is not tried again. Any other failure is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, then waits for staff with a notice that says so. A new status of the order tries that order's failed movement at once, and "Apply outstanding stock movements" in Tools > Database Sync tries every failed movement at once.

The events at a glance​

All of them are announced to everyone (broadcast). Alfa loads the alfa plugin group before it announces them.

EventFiresCan it refuse?If your listener throws
onInventoryBeforeReserveBefore stock is set asideYesThe step stops
onInventoryBeforeCommitBefore goods are taken from stockYesThe step stops
onInventoryBeforeReleaseBefore all reservations of an order are let goYesThe step stops
onInventoryReservedAfter stock was set aside and the save is finalNoLogged, the others still run
onInventoryCommittedAfter goods were taken from stock and the save is finalNoLogged, the others still run
onInventoryReleasedAfter reservations were let go and the save is finalNoLogged, the others still run
onInventoryAdjustedAfter a manual adjustment and the save is finalNoLogged, the others still run
onInventoryTransferredAfter a transfer and the save is finalNoLogged, the others still run
onInventoryRestoredPlanned for 2.2.1, which is not released: after stock was put back, or taken off again when a credit note is voided, and the save is finalNoLogged, the others still run
onCollectStockAllocationProvidersWhen Alfa needs to know where a reservation's stock comes fromNoLogged, the others still run

Put a plugin that listens to the first eight (nine from 2.2.1, which is not released) in the alfa group. The group alfa-inventory is loaded only when Alfa asks for allocation providers, so a plugin of that group is not always loaded when the other stock events are announced.

No plugin that handles these events ships with Alfa Commerce, and no plugin of the group alfa-inventory ships. The samples on this page were checked line by line against the event classes and the interface, and there is no shipped plugin to copy from.

An order line is set aside and taken from stock​

The picture shows an order placed into a status that removes goods from stock. Both steps and the order itself are one save.

If your plugin refuses at step 4 or step 7, nothing of the order is saved and the placement fails. The person sees a general message that the order was not placed. Your message is not shown. It is written to Joomla's log (category com_alfa.orders).

Planned for 2.2.1, which is not released: when your plugin refuses with InventoryRefusedException, the person who places the order reads your sentence: the shopper at checkout, staff in the administrator. Any other exception keeps the general message, and its words stay in the log. So write the sentence for a shopper: no warehouse codes or internal states.

The two "after" events do not follow the save at once. Alfa first hands onOrderAfterPlace to the order's payment plugin and shipping plugin (step 9), and announces the stock events (step 10) when both are done. The whole order of a placement is on Event flows.

When the stock moves because a status changed, there are two saves, and the status is final before your plugin is asked:

If your plugin refuses at step 4, the status stays changed. The stock movement stays owed, staff see a notice, and the background job asks your plugin again when it tries the movement again.

Planned for 2.2.1, which is not released: that holds for an ordinary exception, within the seven tries. A refusal with InventoryRefusedException is final for the movement: it is recorded as refused, not tried again, and its notice carries your sentence (without markup, on one line, at most 300 characters). For an order, staff decide on the order: a new status that changes the order's stock claim tries it again. For a document, a task "Stock movement of a document that a stock plugin refused" stands in Tools > Tasks and the bell, and only its Run again tries it, once your plugin allows it.

The events that can refuse​

These three are "before" events. Alfa announces them with AlfaPluginHelper::dispatch(), so an error thrown by a listener travels back to the stock step and stops it. That is how a listener refuses: it throws an exception. There is no "refuse" method.

Alfa has written nothing for the step when it asks. The step can still be part of a larger save, as when an order is placed. A refusal then undoes that larger save.

Planned for 2.2.1, which is not released: a refusal on purpose is an exception of its own, Alfa\Component\Alfa\Administrator\Service\Inventory\InventoryRefusedException, thrown with the sentence people are to read. Any other exception is a failure, which is tried again.

onInventoryBeforeReserve​

When it fires. At the start of every "set aside" step (InventoryService::reserve()), before anything is written.

Who receives it. Announced to everyone. Event class Alfa\Component\Alfa\Administrator\Event\Inventory\InventoryBeforeReserveEvent.

What you are given.

MethodWhat it gives
getLines(): arrayThe lines to set aside. Each line is an array with item_id (int), combination_id (int or null), quantity (float) and line_id (int, the order line).
getSourceType(): stringWhat asks for the reservation. For an order it is 'order'.
getSourceId(): intThe id of that source. For an order, the order's id.
getContext(): ?InventoryContextWho is acting: userId (0 means the system), correlationId, note and metadata. All four are read-only properties.
updateLines(array $lines): voidReplaces the lines. Alfa sets aside the lines you leave.

What you are expected to do. Nothing, if you have no objection. To change what is set aside, call updateLines() with lines of the same shape. To refuse, throw an exception with a message staff can understand. Do not write to stock tables yourself. Planned for 2.2.1, which is not released: to refuse, throw InventoryRefusedException with a sentence a shopper can read; at checkout the shopper sees it.

What happens next. Alfa takes the lines as the last listener left them and sets them aside. Each line is still checked by Alfa's own rules after you, for example the item's backorder setting.

If you are missing or you fail. If no plugin listens, the lines are set aside as asked. If your listener throws, the step stops:

Where the step ranWhat the person sees
While an order is placedThe order is not placed and nothing is saved. The person sees a general message. Your message is written to the log (category com_alfa.orders).
After a status changeThe status stays changed. The movement stays owed, a notice is raised for staff, and the step is tried again later.
Planned for 2.2.1, which is not released: while an order is placed, with InventoryRefusedExceptionThe order is not placed and nothing is saved. The person reads your sentence.
Planned for 2.2.1, which is not released: after a status change, with InventoryRefusedExceptionThe status stays changed. The movement is refused and not tried again. The notice carries your sentence, and a new status that changes the order's stock claim tries it again.

onInventoryBeforeCommit​

When it fires. At the start of every "take from stock" step (InventoryService::commitReservation()), before anything is deducted. Orders and documents both come through here.

Who receives it. Announced to everyone. Event class Alfa\Component\Alfa\Administrator\Event\Inventory\InventoryBeforeCommitEvent.

What you are given.

MethodWhat it gives
getRequest(): CommitmentRequestWhat is to be taken. A CommitmentRequest has three read-only properties: sourceType ('order' or 'document'), sourceId and lines.
updateRequest(CommitmentRequest $request): voidReplaces the request. A CommitmentRequest cannot be changed, so build a new one.

Each entry of lines is an array:

KeyMeaning
item_idThe item
combination_idThe line's combination, or null
quantityHow much to take (see mode)
orderedThe total ordered quantity of the line
line_idThe order line, or null
mode'target': quantity is the total the line should have out of stock. 'increment': quantity is what this step adds.
location_idA fixed location, or null to use the locations the reservation was placed on

What you are expected to do. Nothing, or replace the request, or refuse by throwing.

What happens next. Alfa takes the goods the final request names. The reservations of those lines become stock movements.

If you are missing or you fail. As for onInventoryBeforeReserve: with no listener the goods are taken as asked; a listener that throws stops the step, with the same two outcomes as in the table above. When the step came from a document, the document stays issued and its stock movement stays owed. Planned for 2.2.1, which is not released: when the refusal is InventoryRefusedException, the document's movement waits for staff as a task with Run again (see above).

onInventoryBeforeRelease​

When it fires. Before all reservations of one source are let go (InventoryService::release()). Alfa does this when an order enters a cancelled status and when an order is deleted.

Who receives it. Announced to everyone. Event class Alfa\Component\Alfa\Administrator\Event\Inventory\InventoryBeforeReleaseEvent.

What you are given.

MethodWhat it gives
getSourceType(): stringWhat held the reservations. For an order it is 'order'.
getSourceId(): intThe id of that source
getToState(): stringThe state the reservations will end in: released, cancelled or expired

What you are expected to do. Nothing, or refuse by throwing. This event has nothing you can change.

What happens next. Alfa lets the reservations go. The stock is free to sell again.

If you are missing or you fail. With no listener the reservations are let go. A listener that throws stops the step, and the reservations stay. Planned for 2.2.1, which is not released: an owed movement refused with InventoryRefusedException is not tried again; any other exception is tried again with the waits above.

The events that only report​

These five are "after" events (six from 2.2.1, which is not released, with onInventoryRestored). They all share one base class, Alfa\Component\Alfa\Administrator\Event\Inventory\InventoryAfterEvent, with two methods:

MethodWhat it gives
getResult(): arrayA short summary of what the step did
argument(string $key, mixed $default = null): mixedOne named value of the event (the tables below list the names)

Who receives them. Each is announced to everyone. The cards below give, for each event, when it fires and what it carries. Everything else is the same for all five and is said here once.

When they fire, exactly. Alfa does not announce them when the step finishes. It waits until the whole save the step belongs to is final (PostCommit::defer()). So:

  • if the step ran alone, the event follows at once, or as soon as a lock Alfa holds is released;
  • if the step was part of a larger save (an order being placed), the event waits for that save and follows it. At a placement it also waits until the order's payment and shipping plugins have handled onOrderAfterPlace;
  • if that save is undone, the event is never announced.

If your listener throws. Alfa announces these with AlfaPluginHelper::dispatchIsolated(). The error is written to Joomla's log (category com_alfa.plugins) with the event and your listener's name, the next listener runs, and the person sees nothing. The stock stays as it was moved.

What you are expected to do. React: notify someone, update another system. You cannot change or undo the movement from here, and you must not write to stock tables.

What to keep in mind.

  • The request that announces the event may not be the one in which a person acted. After a status change the stock can move in background work, where nobody may be signed in. Do not rely on the current user or the session.
  • The same step can be tried more than once. Write the listener so that hearing about one movement twice does no harm. operation_id in the result names one step.

onInventoryReserved​

When it fires. After stock was set aside and the save is final. Event class InventoryReservedEvent.

Name for argument()Meaning
linesThe lines that were asked, as the "before" listeners left them
sourceType, sourceIdWhat asked, as in onInventoryBeforeReserve
resultreserved (lines set aside), skipped (lines left alone), operation_id

onInventoryCommitted​

When it fires. After goods were taken from stock and the save is final. Event class InventoryCommittedEvent.

Name for argument()Meaning
requestThe CommitmentRequest that was carried out
resultdeducted_quantity, over_shipment, lines (how many lines), operation_id

onInventoryReleased​

When it fires. After all reservations of a source were let go and the save is final. Event class InventoryReleasedEvent.

Name for argument()Meaning
sourceType, sourceIdWhat held the reservations
toStatereleased, cancelled or expired
resultreleased_quantity, reservations (how many), operation_id

onInventoryAdjusted​

When it fires. After a manual adjustment and the save is final: staff changed the stock figure of an item or of a combination, or an item's stock tracking was changed with opening quantities (changeTracking()). Event class InventoryAdjustedEvent.

Name for argument()Meaning
itemIdThe item
combinationIdThe combination, or 0 for the item itself
locationIdThe location
quantityDeltaThe change: positive adds stock, negative removes it
movementTypeadjust or receive
sourceType, sourceIdWhat made the change
resultoperation_id, and delta when the change came from a stock figure

onInventoryTransferred​

When it fires. After a quantity moved between two locations (InventoryService::transfer()) and the save is final. Event class InventoryTransferredEvent.

Name for argument()Meaning
itemId, combinationIdWhat moved
quantityHow much
sourceLocationId, destinationLocationIdFrom where, to where
resulttransfer_id, operation_id

onInventoryRestored​

Planned for 2.2.1, which is not released.

When it fires. After stock was put back on the shelf and the save is final: an order cancelled or a line cut, or the return of a credit note (InventoryService::restore()). Also when a credit note is voided and what it put back is taken off again (InventoryService::reverseRestores()). Event class InventoryRestoredEvent. Side effects only, like the other "after" events.

Name for argument()Meaning
sourceType, sourceIdWhat put the stock back
reversaltrue when a voided credit note took back an earlier return. isReversal() reads it.
movementsOne entry for each item, combination and location: itemId, combinationId, locationId and quantity, positive when stock came back, negative for a reversal. movements() reads it.
resultThe summary of the step, with operation_id

What you must provide: a listener​

A feature plugin (group alfa) that refuses large reservations and reacts when stock was set aside:

namespace Acme\Plugin\Alfa\Stockwatch\Extension;

use Alfa\Component\Alfa\Administrator\Event\Inventory\InventoryBeforeReserveEvent;
use Alfa\Component\Alfa\Administrator\Event\Inventory\InventoryReservedEvent;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;
use RuntimeException;

final class Stockwatch extends CMSPlugin implements SubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'onInventoryBeforeReserve' => 'onInventoryBeforeReserve',
'onInventoryReserved' => 'onInventoryReserved',
];
}

// A "before" event: throwing refuses the step.
public function onInventoryBeforeReserve(InventoryBeforeReserveEvent $event): void
{
foreach ($event->getLines() as $line) {
if ((float) ($line['quantity'] ?? 0) > 100) {
throw new RuntimeException('No more than 100 units of one item can be set aside for one order.');
}
}
}

// An "after" event: the save is final, only react.
public function onInventoryReserved(InventoryReservedEvent $event): void
{
if ($event->argument('sourceType') !== 'order') {
return;
}

$orderId = (int) $event->argument('sourceId');
$result = $event->getResult();

// Your own work, for example with $orderId and $result['operation_id'].
}
}

Planned for 2.2.1, which is not released: to refuse in words a shopper reads at checkout, throw InventoryRefusedException instead of RuntimeException. The sample keeps RuntimeException, which 2.2.0 needs.

The provider contract: where the stock comes from​

What it does​

When Alfa sets a quantity aside, it must decide at which location. By default it draws from the enabled locations that hold the goods, and puts what cannot be covered on the default location. A plugin can replace that decision with its own rule, for example "always the location nearest to the customer". The object that holds such a rule is a stock allocation provider: a class that implements Alfa\Component\Alfa\Administrator\Provider\StockAllocationProviderInterface.

When it is used​

Only when stock is set aside. Taking goods from stock later uses the locations the reservation was placed on and does not ask again.

Alfa collects the providers with the event onCollectStockAllocationProviders, then asks them one after the other, the highest priority first. The first usable answer wins.

onCollectStockAllocationProviders​

When it fires. When a "set aside" step first needs to place a quantity.

Who receives it. Announced to everyone. Alfa loads the plugin group alfa-inventory just before it. Event class Alfa\Component\Alfa\Administrator\Event\Inventory\CollectStockAllocationProvidersEvent.

What you are given.

MethodWhat it does
addProvider(StockAllocationProviderInterface $provider, string $contributor, int $priority = 0): voidOffers your provider. $contributor is your plugin's name as group.element, for example alfa-inventory.mainstore. A higher $priority is asked earlier.
getProviders(): arrayThe providers offered so far. Alfa reads it; a plugin has no need to.

What you are expected to do. Call addProvider() once for each provider you offer.

What happens next. Alfa sorts the providers by priority and asks them for each quantity it sets aside.

If you are missing or you fail. With no provider, Alfa uses its own rule. A contributor name that is not group.element makes addProvider() throw InvalidArgumentException. A listener that throws is logged (category com_alfa.inventory) and skipped, and the other providers still answer.

Since 2.2.0 $contributor is the plugin's name as group.element. Earlier versions took the extension's name there.

The interface​

It has one method:

public function allocate(array $unit, float $quantity, InventoryContext $context): ?array;
ArgumentMeaning
$unitWhat is set aside: ['item' => int, 'combination' => int]. A combination of 0 means the item itself.
$quantityHow much is set aside
$contextAn InventoryContext. In 2.2.0 Alfa passes an empty one here: user 0, no note, no metadata. Planned for 2.2.1, which is not released: the reservation's own context.

What your method may do:

Your answerHowWhat Alfa does
AnswerReturn an array of location id to quantity, each quantity above zero, adding up to $quantityPlaces the reservation on those locations
PassReturn nullAsks the next provider, and uses its own rule when all pass
RefuseThrow Alfa\Component\Alfa\Administrator\Service\Inventory\AllocationRefusedExceptionStops the whole "set aside" step. It does not fall back to another location.

What can go wrong​

What happenedWhat Alfa does
Your answer names a location that is disabled or has an id of 0 or lessDrops that part. The quantities then no longer add up, so the answer is discarded.
The quantities do not add up to what was askedWrites a warning to the log (category com_alfa.inventory), discards the answer and asks the next provider
Your method throws any other errorWrites a warning to the log, skips your provider and asks the next one

Your method runs while Alfa holds the stock records of the unit. Keep it quick and do not write to the database in it.

A minimal provider​

The plugin (group alfa-inventory, element mainstore). The setting location_id is this sample's own:

namespace Acme\Plugin\AlfaInventory\Mainstore\Extension;

use Acme\Plugin\AlfaInventory\Mainstore\Allocation\MainStoreAllocation;
use Alfa\Component\Alfa\Administrator\Event\Inventory\CollectStockAllocationProvidersEvent;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;

final class Mainstore extends CMSPlugin implements SubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'onCollectStockAllocationProviders' => 'onCollectStockAllocationProviders',
];
}

public function onCollectStockAllocationProviders(CollectStockAllocationProvidersEvent $event): void
{
$event->addProvider(
new MainStoreAllocation((int) $this->params->get('location_id', 0)),
'alfa-inventory.mainstore',
100,
);
}
}

The provider:

namespace Acme\Plugin\AlfaInventory\Mainstore\Allocation;

use Alfa\Component\Alfa\Administrator\Provider\StockAllocationProviderInterface;
use Alfa\Component\Alfa\Administrator\Service\Inventory\InventoryContext;

final class MainStoreAllocation implements StockAllocationProviderInterface
{
public function __construct(private readonly int $locationId)
{
}

public function allocate(array $unit, float $quantity, InventoryContext $context): ?array
{
// No location chosen in the plugin's settings: pass, Alfa asks the next provider.
if ($this->locationId <= 0) {
return null;
}

// The whole quantity from one location.
return [$this->locationId => $quantity];
}
}

Not documented yet​

  • Putting goods back on the shelf after they were taken (restore), letting go the reservation of a single order line, writing stock off and resolving an order line announce no event in 2.2.0. A plugin cannot hear them.
  • No screen of Alfa Commerce 2.2.0 calls the transfer step, so onInventoryTransferred is announced only when other code calls InventoryService::transfer(). The same is true of receive() and adjust(): every screen corrects a stock figure with adjustTo(). It is also true of reservations that expire by time (expireDueReservations()): core orders carry no expiry. An onInventoryReleased from that step has an empty source, and its result holds expired and released_quantity only.
  • ReservationPolicyInterface (decide when an order sets stock aside) exists in the code, but 2.2.0 always uses its own rule, "set aside when the order is placed". A plugin cannot supply another.
  • What staff see when a "before" listener refuses while an order's lines are edited or an order is deleted.
  • Calling InventoryService from a plugin to move stock.