WPMake

← Back to BlogAdding Custom Actions to the WooCommerce My Account Orders Page (Developer Guide)TUTORIAL

Adding Custom Actions to the WooCommerce My Account Orders Page (Developer Guide)

⏱ 7 min read  ·  👤 iamprazol  ·  Sep 5, 2026

WooCommerce gives you a supported way to add buttons to the Actions column of the My Account orders table. It is one filter, it has been stable since WooCommerce 2.0, and it is a much better idea than overriding myaccount/orders.php.

This is the mechanical guide: the signature, the security, the column equivalent, and the storage rule that quietly breaks code on migrated stores.

The filter and what it receives

woocommerce_my_account_my_orders_actions is applied in wc_get_account_orders_actions(), in includes/wc-account-functions.php. It takes two arguments: the actions array and the WC_Order.

By the time your callback runs, WooCommerce has already built its own defaults and pruned them: pay is removed unless needs_payment(), and cancel is removed unless the status passes woocommerce_valid_order_statuses_for_cancel, which defaults to pending and failed.

Each entry is an array with url, name, and optionally aria-label. After your filter runs, WooCommerce discards any entry that is not an array with both name and url as strings — so a malformed action is dropped rather than rendered, and you will get no error telling you why your button did not appear.

Adding an action, with the nonce

add_filter(
    'woocommerce_my_account_my_orders_actions',
    'myplugin_add_reorder_review_action',
    10,
    2
);

function myplugin_add_reorder_review_action( $actions, $order ) {
    if ( ! $order instanceof WC_Order ) {
        return $actions;
    }

    if ( ! $order->has_status( array( 'completed', 'processing' ) ) ) {
        return $actions;
    }

    $url = add_query_arg(
        array( 'myplugin_review' => $order->get_id() ),
        wc_get_account_endpoint_url( 'orders' )
    );

    $actions['myplugin_review'] = array(
        'url'        => wp_nonce_url( $url, 'myplugin_review_' . $order->get_id() ),
        'name'       => __( 'Buy these again', 'myplugin' ),
        'aria-label' => sprintf(
            /* translators: %s: order number */
            __( 'Buy the items from order %s again', 'myplugin' ),
            $order->get_order_number()
        ),
    );

    return $actions;
}

Three details worth copying.

The nonce is per order. 'myplugin_review_' . $order->get_id(), not a single global action name. A shared nonce lets a customer take a valid nonce from their own order and replay it against a different order ID.

wc_get_account_endpoint_url(), not a hardcoded path. Account endpoint slugs are configurable and translated. Hardcoding /my-account/orders/ breaks on any store that renamed the endpoint or runs a translation plugin.

A real aria-label. Five buttons all announced as “Buy these again” is a screen-reader user’s nightmare. Core’s own actions include the order number for this reason.

Two custom action links rendered on a customer order page: request cancellation, and get help with this order
Two actions added through the filter. What renders is not what is authorised — that check happens in the handler.

Hiding a button is not authorisation

This is the part most snippets on the internet leave out, so it is worth being blunt about.

Your filter decides what to render. It does not decide what can be submitted. An attacker never sees your button; they see a URL pattern, and they will try it with someone else’s order ID. WooCommerce order IDs are sequential, so guessing valid ones is trivial.

Every handler needs its own checks, repeated in full:

add_action( 'template_redirect', 'myplugin_handle_review_action' );

function myplugin_handle_review_action() {
    if ( ! isset( $_GET['myplugin_review'] ) ) {
        return;
    }

    $order_id = absint( wp_unslash( $_GET['myplugin_review'] ) );

    // 1. Nonce, bound to this specific order.
    if ( ! isset( $_GET['_wpnonce'] )
        || ! wp_verify_nonce(
            sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ),
            'myplugin_review_' . $order_id
        )
    ) {
        wc_add_notice( __( 'That link has expired. Please try again.', 'myplugin' ), 'error' );
        return;
    }

    // 2. The order has to exist.
    $order = wc_get_order( $order_id );

    if ( ! $order instanceof WC_Order ) {
        return;
    }

    // 3. Ownership. This is the check that matters.
    if ( ! is_user_logged_in()
        || get_current_user_id() !== $order->get_customer_id()
    ) {
        wc_add_notice( __( 'You cannot do that.', 'myplugin' ), 'error' );
        return;
    }

    // 4. The same eligibility rule the button used.
    if ( ! $order->has_status( array( 'completed', 'processing' ) ) ) {
        return;
    }

    wc_nocache_headers();

    myplugin_do_the_thing( $order );
}

Four checks, and none of them is optional.

Note the ownership comparison uses get_customer_id(). Be careful with the guest case: a guest order has a customer ID of 0, and a logged-out visitor also has a user ID of 0. Comparing them without the is_user_logged_in() guard means every logged-out visitor passes the ownership check for every guest order on the store. Core’s own cancel_order capability has this property by design, backed by the order key in the URL — if you are handling guest orders, you need an equivalent secret, not just an ID.

If this is a state-changing action, do not do it on GET at all. A link prefetcher will fire it, and so will a mail scanner. Render a confirmation page and act on the POST.

Adding a column

Two hooks, and the naming is inconsistent in a way that costs people an hour.

The columns filter is woocommerce_account_orders_columns. The older woocommerce_my_account_my_orders_columns has been deprecated since WooCommerce 2.6 — it still works through WC_Deprecated_Filter_Hooks, which is exactly why the deprecation goes unnoticed for years.

The per-cell action, meanwhile, kept the long name: woocommerce_my_account_my_orders_column_{$column_id}.

add_filter(
    'woocommerce_account_orders_columns',
    function ( $columns ) {
        $out = array();

        foreach ( $columns as $key => $label ) {
            $out[ $key ] = $label;

            if ( 'order-status' === $key ) {
                $out['order-progress'] = __( 'Progress', 'myplugin' );
            }
        }

        return $out;
    }
);

add_action(
    'woocommerce_my_account_my_orders_column_order-progress',
    function ( $order ) {
        echo esc_html( myplugin_progress_label( $order ) );
    }
);

The template only calls your action if has_action() returns true for that column ID, so a column declared without a matching renderer produces an empty cell rather than a notice. Rebuild the array in a loop rather than appending, or your column lands after Actions, which looks wrong on every theme.

One performance rule: this action runs once per row. Whatever myplugin_progress_label() does, it must not query. Read from order meta the CRUD object already loaded, or prime what you need in one pass before the table renders. A single extra query per row is invisible on your test store and obvious on a customer with sixty orders.

HPOS: use CRUD, never postmeta

This is the one that breaks code on stores that have migrated, while working perfectly on yours.

With High-Performance Order Storage enabled, orders live in dedicated tables. They are not posts. get_post_meta( $order_id, '_my_key', true ) reads from wp_postmeta, where there is no longer a row — and it does not error. It returns an empty string, your feature silently stops working, and nothing in the logs says why.

// Wrong. Works on legacy storage, silently returns nothing on HPOS.
$value = get_post_meta( $order_id, '_my_key', true );
update_post_meta( $order_id, '_my_key', $value );

// Right. Works on both.
$order = wc_get_order( $order_id );
$value = $order->get_meta( '_my_key' );

$order->update_meta_data( '_my_key', $value );
$order->save();

The same rule applies to queries. WP_Query against post_type => 'shop_order' is a legacy-storage assumption; use wc_get_orders(). And do not read $_GET['post'] on order admin screens — the HPOS order editor uses a different screen and a different parameter.

If you distribute a plugin, declare compatibility explicitly so WooCommerce stops warning merchants about you:

add_action(
    'before_woocommerce_init',
    function () {
        if ( class_exists( AutomatticWooCommerceUtilitiesFeaturesUtil::class ) ) {
            AutomatticWooCommerceUtilitiesFeaturesUtil::declare_compatibility(
                'custom_order_tables',
                __FILE__,
                true
            );
        }
    }
);

Declare it only once you have actually tested with the setting on. The declaration is a promise, and merchants act on it.

Testing checklist

  • HPOS on and off. Both, every time.
  • An order belonging to another customer, hit directly by URL. You should be refused.
  • A guest order, logged out.
  • A stale nonce — wait it out or fake it. You should get a message, not a fatal.
  • A customer with sixty orders. Watch the query count.
  • A mobile viewport. Account tables are responsive through theme CSS, and extra columns are where that fails.

If you are adding several actions rather than one, the wider guide to changing this page covers the theme-conflict side, which is the other half of shipping this safely.

Adding Custom Actions to the WooCommerce My Account Orders Page (Developer Guide)