Skip to content
System nominalUTC --:--:--Articles 080
MODULE 04 Lesson 10 / 18 30 MIN

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.

Lab 30 minutes

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_changed and woocommerce_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 = false and 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 and get_status().
  • Print the raw status column from the orders table and the post_status of 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.