Order Status Hooks
Nine hooks across an order record's life: creation, status changes, configuration, expiry and deletion.
Overview
An order is the cart's lasting record. Its status decides when services open: a waiting order opens nothing, an active one builds the services.
Carts left unpaid drop on their own after a while. That flow has a hook of its own and runs from a scheduled task.
Reference
Stopping an order record
Runs before the order record is written.
Hook::add('gate:order.create', 10, function ($data) {
if (Acme::blocked((int) ($data['user_id'] ?? 0)))
return 'This account cannot place new orders.';
return null;
});Following an order record
Runs after the order row was written. The services behind it are not built yet.
Hook::add('action:order.created', 10, function ($order) {
// The services are NOT built yet: they open when the status turns active.
Crm::orderRecord((int) ($order['id'] ?? 0), $order);
});Stopping an order status change
Runs before an order status changes.
Hook::add('gate:order.status_change', 10,
function ($order_id, $status, $apply_on_module) {
// A change that skips the module builds nothing: shape the rule for that.
if ($status === 'active' && $apply_on_module && !Acme::capacityFree())
return 'No new service can open now; capacity is full.';
return null;
});Following an order status
Runs after an order status changed.
waiting, inprocess, active, cancelled.Hook::add('action:order.status_changed', 10, function ($id, $status, $old_status) {
// On the first write the old status is EMPTY: a plain comparison misleads.
if ($old_status === '' || $status === $old_status) return;
if ($status === 'active') Crm::orderActivated($id);
});Following an order update
Runs after an order record was updated.
Hook::add('action:order.updated', 10, function ($id, $data) {
Audit::orderEdited((int) $id, array_keys($data));
});Catching an abandoned cart
Runs after an unpaid order timed out and was cancelled.
Hook::add('action:order.expired', 10, function ($order_id, $ordernum, $user_id) {
// An abandoned cart: the best moment for a win-back.
Retention::abandoned((int) $user_id, $ordernum);
});Stopping an order delete
Runs before an order is deleted.
Hook::add('gate:order.delete', 10, function ($ids) {
// An ARRAY arrives even for a single delete.
foreach ($ids as $id)
if (Acme::hasPaidInvoice((int) $id))
return 'An order with a paid invoice cannot be deleted.';
return null;
});Following an order delete
Runs after an order was deleted.
Hook::add('action:order.deleted', 10, function ($id, $order) {
// The items arrive decoded: no JSON parsing needed.
Accounting::orderRemoved((int) $id, $order['items'] ?? []);
});Changing the order total
Runs after the order totals were worked out.
total, tax_amount, the extra tax details, the display subtotal. total is the amount written permanently onto the order.Hook::add('filter:order.total', 10,
function (&$tax_calc, $subtotal, $total_discount, $items) {
// total is written PERMANENTLY; touching the tax keys shapes the
// invoice lines as well.
if (Acme::roundUp()) $tax_calc['total'] = ceil((float) $tax_calc['total']);
});Pitfalls
On the order-created hook the status is usually waiting and the services behind it are not built. A listener doing service work finds nothing here; the right moment is the status turning active.
The previous status on the status event can be an empty string: the order is being written for the first time. A listener comparing "it was this, now it is that" gets it wrong there. Handle the empty value separately.
Even for a single order the gate takes an array. A listener expecting one id tries to read the array as a number and the rule never holds. Always walk it with a loop.
The third parameter of the status gate says whether the change reaches the provider module. Where it is false only the record moves and nothing opens on a server. A rule about capacity, servers or providers has to read it.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.