Orders and communication
The Order Lifecycle and Status Transitions
Status is the field every other subsystem reads. Getting the transition hooks and their firing order wrong breaks stock, email and reports at the same time.
A store adds a small extension that reduces stock when an order reaches a custom "ready to ship" status. It works in testing. In production, stock comes off twice on some orders and not at all on others. Two extensions listened to two different status hooks, and one of them fires only on the transitions somebody remembered to subscribe to.
Status is the field every other subsystem reads. Stock reduction, download permissions, the customer's confirmation email, the revenue reports, and the customer's own "can I pay this again" page all make decisions from it. That makes the transition machinery the highest-leverage code in this module, and the easiest to get subtly wrong, because the hooks you are allowed to use are not interchangeable.
Everything below was checked against WooCommerce 11.2.1 on WordPress 7.0.7 with PHP 8.5. Where I describe an experiment, the output is the real output from a store running with the custom order tables enabled.
Order status is a vocabulary, and an unrecognised word becomes pending
WooCommerce keeps its statuses in one map, returned by
wc_get_order_statuses(). On a clean 11.2 store there are seven of them,
plus one that the Blocks checkout adds through a filter:
<?php
print_r( wc_get_order_statuses() );
Array
(
[wc-pending] => Pending payment
[wc-processing] => Processing
[wc-on-hold] => On hold
[wc-completed] => Completed
[wc-cancelled] => Cancelled
[wc-refunded] => Refunded
[wc-failed] => Failed
[wc-checkout-draft] => Draft
)
The keys carry the wc- prefix because the map doubles as the list of
WordPress post statuses. WC_Order::get_status() returns the slug without
the prefix, and both forms are accepted by most helpers, so a string comparison
against the wrong form is a common way to write a hook that never fires.
The operational meaning of each one is worth stating, because "on hold" and "pending" are confused constantly. Pending payment is an order nobody has paid and the customer may still complete. Failed is a declined payment attempt. On hold is an order waiting for something before it can be fulfilled, and WooCommerce treats it as a valid source for a completed payment, which is why bank transfer orders sit there. Processing means paid and awaiting fulfilment. Completed means fulfilled. Cancelled and Refunded are terminal for the customer's purposes.
That list is also the validation list. WC_Abstract_Order::set_status()
builds get_valid_statuses() from array_keys( wc_get_order_statuses() ),
and anything not in it is replaced with pending. Nothing throws, nothing
logs, and nothing tells the caller. Here is a saved order being moved to a status that
was never registered:
before: completed
returned true, status now pending
changed hooks: completed -> pending
note [system] Order status changed from Completed to Pending payment.
Read that output twice, because it is worse than a rejection. An order sitting in
completed was asked to move to a status that does not exist. WooCommerce
substituted pending, wrote a note saying so, fired
woocommerce_order_status_changed with completed and
pending, and returned true. Every listener that reduces
stock, grants download access or contacts a fulfilment service woke up for a status
nothing asked for, and the caller was told the save succeeded. The boolean is worth
checking for one case only: an order that does not exist yet, where
update_status() returns false without doing anything.
update_status() is set_status() plus save, and the save is what fires hooks
WC_Order::update_status( $new_status, $note = '', $manual = false )
refuses to run on an order with no id, calls set_status() with the same
three arguments, and then calls save(). If anything throws, it logs to the
WooCommerce log, adds an error note to the order and returns false.
The important half is save(). Status transition hooks do not fire from
inside set_status(). They fire from the order's
save(), which writes through the data store first and then runs
status_transition(). So by the time any transition hook runs, the new status
is already in the database. Every hook in the chain below sees the order as it has been
written, not as it is about to be written.
-- update_status( 'completed', '', true ) as an admin --
inside woocommerce_order_status_completed: db says wc-completed, object says completed
inside ..._pending_to_completed: db says wc-completed
inside woocommerce_order_status_changed: db says wc-completed
That row came from a hook that reads
SELECT status FROM wp_wc_orders WHERE id = %d while standing inside the
status hook. It answers the question "can I read the new status from the data store in
my hook" with yes, and it shows the two forms side by side: the database holds the
prefixed wc-completed, the object holds the unprefixed
completed.
One transition fires the hooks in a fixed order, and the order matters
A single move from pending to processing fires four hooks, plus a fifth when the pair qualifies as a payment. The sequence below is the real firing order, captured by registering a listener on each hook and appending to an array:
fired in order: woocommerce_order_edit_status
-> woocommerce_order_status_processing
-> woocommerce_order_status_pending_to_processing
-> woocommerce_order_status_changed
-> woocommerce_order_payment_status_changed
Walking that list against
WC_Order::status_transition() explains each entry.
woocommerce_order_edit_status fires earlier than the rest, from
set_status() itself, and only when $manual is true. Its
arguments are the order id and the new status, nothing else. It is the wrong hook for
reacting to a transition, because most transitions are not manual, but it is the right
hook for "a human used the order screen".
woocommerce_order_status_processing is the dynamic destination hook, the
status name appended to a fixed prefix. It fires first of the three that carry
transition information, before the transition note is written.
woocommerce_order_status_pending_to_processing is the dynamic pair hook,
built from woocommerce_order_status_{from}_to_{to}. It receives the order
id and the order object. It only exists for the pair it names, which is exactly why the
stock bug at the top of this lesson happened: an extension subscribed to
on-hold_to_processing never hears about
pending_to_processing, and neither of them hears about a manager moving an
order by hand.
woocommerce_order_status_changed fires last of the three, with four
arguments: order id, old status, new status, and the order object. Both statuses arrive
without the wc- prefix. This is the hook to use when you mean "the status
changed, whatever the reason".
woocommerce_order_payment_status_changed is conditional. It fires when
the old status is in the valid-for-payment list and the new status is in the paid list,
which on a default store means pending or failed to processing or completed. If your
extension is reacting to money, this is closer to the truth than any status name, but
woocommerce_payment_complete is the one that means a gateway reported
success. The condition is also narrower than it looks: on-hold, the status bank transfer
orders wait in, is not in the valid-for-payment list, so an on-hold order that is paid
does not fire this hook.
The note is written between the destination hook and the pair hook
status_transition() fires the destination hook, then the transition
note, then the pair hook and the change hook. Anything that reads the order's notes
from inside a status hook sees the note only in the last two.
The note and manual arguments are two different controls
The second and third arguments of update_status() are not variations of
the same thing.
$note is text prepended to the automatic transition note. The result is
one note, not two: "Packed by Ana. Order status changed from Pending payment to
Processing." Pass false instead of a string and no note is written at all,
including the automatic one. That is the switch to use when a status change is an
internal bookkeeping step nobody needs to read.
$manual decides who the note is attributed to. It is passed through to
add_order_note() as $added_by_user, and that argument only
does something when a user is logged in and holds the edit_shop_orders
capability for the order. Both conditions true, and the note is filed under the
admin's display name. Either condition false, and the note is filed as WooCommerce:
that is why the same update_status( ..., true ) call produces an
[admin] note from the order screen and a [system] note from a
cron job with no user.
<?php
// An internal status move nobody needs to read: no note at all.
$order->update_status( 'sifero-packed', false );
// A move a support person should be able to see, attributed to whoever ran it.
$order->update_status( 'sifero-packed', 'Packed at the counter.', true );
The third thing $manual does is not documented in its name: it decides
whether woocommerce_order_edit_status fires. A background job that passes
true will announce itself as a manual edit to every listener that hook
has.
On HPOS the status is not in post_status, and the post still exists
On a store using the custom order tables, the status lives in the
status column of wp_wc_orders, with the wc-
prefix, in a varchar(20). The legacy write to
wp_posts.post_status does not happen, because the HPOS data store never
calls wp_update_post() for a status change.
The part that trips people up is that a post row still exists. WooCommerce creates a
placeholder post for every HPOS order so that plugins which ask for a post object get
one. That row has the post type shop_order_placehold and the post status
draft, and it keeps both for the life of the order, whatever happens to the
real status. Read after that order was moved to processing and then completed:
wc_orders: id=20 status=wc-processing type=shop_order
wp_posts: ID=20 post_type=shop_order_placehold post_status=draft
So a query filtering post_status = 'wc-completed' returns nothing on an
HPOS store, without an error. It is the same failure shape as the postmeta read in the
first lesson of this course, and it has the same fix: ask the order. A
wc_get_orders( array( 'status' => 'completed' ) ) call goes through the
data store that knows which storage the store is on.
That varchar(20) is a limit rather than a description, and the analytics
table shares it. The prefixed slug has to fit, so keep a custom status to 17
characters.
Write a script you load with wp eval-file that moves one order
through three transitions and records what happened. Your script must:
- Create an order, save it, and print its id and status.
- Register one listener that appends the hook name to an array on each of
woocommerce_order_edit_status,woocommerce_order_status_pending_to_processing,woocommerce_order_status_processing,woocommerce_order_status_changedandwoocommerce_order_payment_status_changed. - Move the order pending to processing with
update_status( 'processing', 'Packed by the lab.', true )and print the array in firing order. - Move it processing to completed with
$note = falseand print the order's notes, so you can show that no note was written. - Attempt
update_status( 'sifero-does-not-exist' )and print both the return value andget_status(). - Print the raw
statuscolumn from the orders table and thepost_statusof the placeholder post for the same id.
Then, in one sentence each: which hook you would use to reduce stock when an order becomes paid, and what the job's fourth line tells you about the status name you passed.
Show the solution and the reasoning
The script is a diagnostic, so it is allowed to be ugly. What matters is that it prints the firing order rather than asserting it, because the order is the thing you are going to argue about with a colleague.
<?php
$hooks = array(
'woocommerce_order_edit_status',
'woocommerce_order_status_processing',
'woocommerce_order_status_pending_to_processing',
'woocommerce_order_status_changed',
'woocommerce_order_payment_status_changed',
);
$fired = array();
foreach ( $hooks as $hook ) {
add_action(
$hook,
function () use ( $hook, &$fired ) {
$fired[] = $hook;
}
);
}
$order = wc_create_order();
$order->set_status( 'pending' );
$order->save();
$id = $order->get_id();
$order->update_status( 'processing', 'Packed by the lab.', true );
printf( "pending -> processing: %s\n", implode( ' -> ', $fired ) );
$fired = array();
$order->update_status( 'completed', false );
printf( "processing -> completed: %s\n", implode( ' -> ', $fired ) );
foreach ( wc_get_order_notes( array( 'order_id' => $id ) ) as $note ) {
printf( "note [%s] %s\n", $note->added_by, trim( wp_strip_all_tags( $note->content ) ) );
}
$ok = $order->update_status( 'sifero-does-not-exist' );
printf( "unknown status: returned %s, status is %s\n", var_export( $ok, true ), $order->get_status() );
global $wpdb;
printf(
"wc_orders.status=%s wp_posts.post_status=%s\n",
$wpdb->get_var( $wpdb->prepare( "SELECT status FROM {$wpdb->prefix}wc_orders WHERE id = %d", $id ) ),
get_post( $id )->post_status
);
pending -> processing: woocommerce_order_edit_status -> woocommerce_order_status_processing -> woocommerce_order_status_pending_to_processing -> woocommerce_order_status_changed -> woocommerce_order_payment_status_changed
processing -> completed: woocommerce_order_status_completed -> woocommerce_order_status_processing_to_completed -> woocommerce_order_status_changed
note [system] Packed by the lab. Order status changed from Pending payment to Processing.
unknown status: returned true, status is pending
wc_orders.status=wc-pending wp_posts.post_status=draft
Three things in that transcript matter more than the script. The pair hook changes
name with both statuses, the destination hook with one, and
woocommerce_order_status_changed keeps its name across every transition.
The second transition fires no payment hook, because both sides of it are already paid.
And the notes list contains nothing from that transition: false suppressed
the automatic note along with the custom text, which is why an extension's status
changes sometimes leave no trace in the order screen.
The unknown status line is the one to keep. update_status() returned
true for a status that does not exist, the order landed in
pending, and the payment hook fired on the way there.
For the stock question: reduce stock on
woocommerce_order_status_processing if you mean "paid and being
fulfilled, whatever came before", and on
woocommerce_order_payment_status_changed if you need to be narrower. Avoid
a specific pair hook, because pairs are the transitions somebody remembered to write
down, and a store that reaches processing from on-hold as often as from pending will
halve or double a pair listener's work.
The destination hook has a cost worth naming. It fires on a manual change from the
order screen and on a bulk action in the orders list, so "the customer paid" and "a
manager clicked Processing" arrive at the same place. If your code has to tell those
apart, read $order->get_date_paid() rather than inferring it from the
status.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.