Hook into WooCommerce Order Status Transitions and Lifecycle Events

WooCommerce order status represents the current state of a customer order as it progresses from placement to fulfillment — statuses include pending, processing, on-hold, completed, cancelled, refunded, and failed, plus any custom statuses registered by extensions. Understanding the order lifecycle and its hook architecture is essential for building fulfillment integrations, ERP syncs, email triggers, and analytics — each status change fires a predictable set of hooks that allow plugins to react without polling the database. The general hook woocommerce_order_status_changed fires on every status transition with the order ID, old status, and new status as parameters — use this for generic logging or audit trails. Transition-specific hooks follow the pattern woocommerce_order_status_{old_status}_to_{new_status} (e.g., woocommerce_order_status_pending_to_processing) for handling specific state machine transitions, and woocommerce_order_status_{new_status} (e.g., woocommerce_order_status_completed) for reacting to entering a specific status regardless of origin. Custom order statuses are registered via register_post_status() and added to WooCommerce’s status list via wc_register_order_status() and the wc_order_statuses filter — the custom status then participates in the same hook architecture. Order notes, added with $order->add_order_note(), create an audit trail visible in the order admin screen and, when the second parameter is true, trigger an email to the customer. Order data should always be read and written through the WC_Order CRUD methods (get_status(), set_status(), save()) rather than directly updating wp_posts or wp_postmeta — WooCommerce 6.0+ introduced a HPOS (High-Performance Order Storage) option that stores orders in custom tables, so direct wp_postmeta writes bypass HPOS and break data integrity. The variable products post covers the product side; this post covers the order side of the WooCommerce data model.

Problem: A WooCommerce store needs to: (1) notify a fulfillment warehouse API when an order moves to "processing", (2) send a custom SMS to the customer when the order ships ("completed"), and (3) log all order status changes to a custom analytics table — currently done with a single save_post hook that fires on every post save, causing duplicate calls and missing context about the previous status.

Solution: Use WooCommerce’s dedicated order status hooks — transition-specific hooks for the warehouse notification and SMS, and the general woocommerce_order_status_changed hook for the analytics logging — ensuring each action fires exactly once per relevant transition.

// ── 1. Notify warehouse when order enters "processing" status ─────────────
add_action( 'woocommerce_order_status_pending_to_processing', 'myplugin_notify_warehouse', 10, 2 );
add_action( 'woocommerce_order_status_on-hold_to_processing', 'myplugin_notify_warehouse', 10, 2 );

function myplugin_notify_warehouse( int $order_id, WC_Order $order ): void {
    $payload = [
        'order_id'       => $order_id,
        'customer_name'  => $order->get_formatted_shipping_full_name(),
        'shipping_addr'  => $order->get_formatted_shipping_address(),
        'items'          => array_map( fn( $item ) => [
            'sku'      => $item->get_product()->get_sku(),
            'quantity' => $item->get_quantity(),
        ], $order->get_items() ),
    ];

    $response = wp_remote_post( 'https://warehouse.example.com/api/orders', [
        'timeout'     => 10,
        'headers'     => [ 'Content-Type' => 'application/json' ],
        'body'        => wp_json_encode( $payload ),
        'data_format' => 'body',
    ] );

    if ( is_wp_error( $response ) ) {
        $order->add_order_note( 'Warehouse notification failed: ' . esc_html( $response->get_error_message() ) );
        return;
    }

    $order->add_order_note( 'Warehouse notified. Reference: ' .
        esc_html( json_decode( wp_remote_retrieve_body( $response ), true )['reference'] ?? 'N/A' )
    );
}

// ── 2. SMS customer when order is marked completed (shipped) ──────────────
add_action( 'woocommerce_order_status_completed', 'myplugin_send_shipping_sms', 10, 2 );

function myplugin_send_shipping_sms( int $order_id, WC_Order $order ): void {
    $phone = $order->get_billing_phone();
    if ( ! $phone ) return;

    // Sanitize phone — allow digits, +, -, spaces, parentheses only
    $phone = preg_replace( '/[^0-9+\-() ]/', '', $phone );

    $message = sprintf(
        __( 'Hi %s! Your order #%d has shipped. Track it at: %s', 'myplugin' ),
        sanitize_text_field( $order->get_billing_first_name() ),
        $order_id,
        esc_url( $order->get_view_order_url() )
    );

    // Queue SMS via Action Scheduler (non-blocking)
    as_schedule_single_action( time(), 'myplugin_dispatch_sms', [ $phone, $message ], 'sms-queue' );
}

// ── 3. Log all status changes to analytics table ─────────────────────────
add_action( 'woocommerce_order_status_changed', 'myplugin_log_status_change', 10, 4 );

function myplugin_log_status_change( int $order_id, string $old_status, string $new_status, WC_Order $order ): void {
    global $wpdb;
    $wpdb->insert(
        $wpdb->prefix . 'myplugin_order_status_log',
        [
            'order_id'   => $order_id,
            'old_status' => sanitize_key( $old_status ),
            'new_status' => sanitize_key( $new_status ),
            'changed_at' => current_time( 'mysql', true ),
            'user_id'    => get_current_user_id(),
        ],
        [ '%d', '%s', '%s', '%s', '%d' ]
    );
}

// ── 4. Register a custom "awaiting-pickup" status ─────────────────────────
add_action( 'init', function(): void {
    register_post_status( 'wc-awaiting-pickup', [
        'label'                     => _x( 'Awaiting Pickup', 'Order status', 'myplugin' ),
        'public'                    => true,
        'exclude_from_search'       => false,
        'show_in_admin_all_list'    => true,
        'show_in_admin_status_list' => true,
        'label_count'               => _n_noop( 'Awaiting Pickup (%s)', 'Awaiting Pickup (%s)', 'myplugin' ),
    ] );
} );

add_filter( 'wc_order_statuses', function( array $statuses ): array {
    $statuses['wc-awaiting-pickup'] = _x( 'Awaiting Pickup', 'Order status', 'myplugin' );
    return $statuses;
} );

NOTE: When reading order data inside status hooks, always use the WC_Order object passed as the second parameter rather than calling wc_get_order( $order_id ) again — the passed object is already loaded and avoids a redundant database query. In hooks that only receive the order ID (like the generic woocommerce_order_status_completed without a second parameter bound), call wc_get_order( $order_id ) and check for a falsy return before accessing any order methods, since the order may have been deleted between hook registration and execution in async environments.