Skip to content
System nominalUTC --:--:--Articles 080
MODULE 04 Lesson 11 / 18 35 MIN

Orders and communication

Custom Order Statuses

Adding a status to the dropdown is the easy half. The hard half is every list WooCommerce consults to decide what a customer may still do with the order.

A store adds a "Packed for pickup" status so the counter staff can move an order along without marking it complete. The status appears in the order edit screen, staff use it, and the orders list filters to it. Two weeks later the store owner reports that the payment link on the customer's order page says the order is not payable, and that the export of packed orders came back with the entire order table in it. The status works in the one place it was tested and fails in three places nobody looked.

Order status is a shared vocabulary, and registering a word in it means answering four different questions: can an order be stored in it, does it appear in lists, is it considered paid, and what is the customer allowed to do while the order sits there. Each of those questions has its own filter or registration call, and each one can be answered wrongly without an error.

The dropdown comes from wc_order_statuses, and that filter is the validation list

The map returned by wc_get_order_statuses() ends with apply_filters( 'wc_order_statuses', $order_statuses ), so adding a key is all it takes for a status to appear in the order edit dropdown, the orders list filter, and every other place that renders the list. The same filter also decides whether WC_Order::set_status() will accept the value, because get_valid_statuses() is built from array_keys( wc_get_order_statuses() ).

<?php
add_filter(
	'wc_order_statuses',
	function ( $statuses ) {
		$statuses['wc-sifero-packed'] = 'Packed for pickup';
		return $statuses;
	}
);

That is the whole of the visible half, and it is where most tutorials stop. The status now round-trips through the order object and shows up in the admin. It is also not registered with WordPress, which matters the moment anything queries orders by post status.

register_post_status() is what makes the status real

Order statuses are WordPress post statuses, which is why every core key carries the wc- prefix. WooCommerce registers its seven through register_post_status() on the init action at priority 9, using the map from the woocommerce_register_shop_order_post_statuses filter. A custom status needs the same treatment, on a priority at or after 9 so it does not load before the core ones.

<?php
add_action(
	'init',
	function () {
		register_post_status(
			'wc-sifero-packed',
			array(
				'label'                     => 'Packed for pickup',
				'public'                    => false,
				'exclude_from_search'       => false,
				'show_in_admin_all_list'    => true,
				'show_in_admin_status_list' => true,
				'label_count'               => _n_noop(
					'Packed for pickup <span class="count">(%s)</span>',
					'Packed for pickup <span class="count">(%s)</span>'
				),
			)
		);
	},
	9
);

The two calls do different jobs, and the pair matters more than either one. The filter decides whether WooCommerce will let you write the status. The registration decides whether WordPress knows the status exists, which is what get_post_stati() reads and what every ordinary WP_Query consults.

Each missing piece produces a different failure, and only one of them is loud

The useful thing about splitting the two steps is that you can predict what breaks when one is missing. On a store running the custom order tables, this is what the four combinations do.

Filterregister_post_status()What happens to an order moved into the status
yes yes The status is stored with its wc- prefix and every list agrees.
yes no Stored, but the slug is written without the wc- prefix. Queries that filter on the prefixed form find nothing.
no yes The transition is refused and the order silently lands in pending.
no no Same as the row above, and the status never appears in the dropdown either.

The second row is the one worth understanding in code. The storage layer asks WordPress whether the status is registered before it adds the prefix, and a status that is only in the filter fails that test:

<?php
// Abstract_WC_Order_Data_Store_CPT::get_post_status(), abbreviated.
$post_status = $order_status;
if ( ! in_array( $post_status, array( 'auto-draft', 'draft', 'trash' ), true )
	&& in_array( 'wc-' . $post_status, get_post_stati(), true ) ) {
	$post_status = 'wc-' . $post_status;
}

Run on an HPOS store with the filter but no registration, an order moved into the status ends up with the raw slug in the status column:

B wc_is_order_status: true | post status object: false
sifero-filteronly      -> status sifero-filteronly       column 'sifero-filteronly'

Everything in WooCommerce still works, because OrdersTableQuery prefixes the slug itself when it is in wc_get_order_statuses(). What changes is the value that lands in storage, and anything that goes looking for the prefixed form finds nothing. On the posts-based store the same order is stored as sifero-fo in wp_posts.post_status, and there the failure is worse, because WordPress silently ignores an unregistered status in a query. It does not return nothing:

raw='sifero-fo'
sql count wc-=0, plain=1
wc- query found=8 posts=17,16,15,14,13,11,12,10 request=SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
    FROM wp_posts WHERE 1=1 AND wp_posts.post_type = 'shop_order'

Look at the generated SQL. The post_status clause is gone, and the query returned every order on the store. A reporting plugin that counts post_status = 'wc-sifero-packed' does not fail and does not return zero, it returns the whole order table and prints a number that looks plausible. That is the shape of this bug: a wrong answer that no log records, on a store where the orders list still filters correctly, because the orders list goes through WooCommerce rather than through WP_Query.

Registering the status fixes it on the store where the query used to ignore the clause entirely. On the posts-based store, with both calls in place, the same probe reads:

raw post_status='wc-sifero-legit'
WP_Query(prefixed): found=1 has_our_id=yes
wc_get_orders: 18

On an HPOS store that WP_Query line reads zero, and that is not a bug either. Orders there are rows in wc_orders with a placeholder post beside them, so a query over wp_posts is asking the wrong table. WooCommerce filters by status through wc_get_orders() on both storages, which is the same conclusion the first lesson of this course reached from the other direction.

Twenty characters, counting the prefix

wc_orders.status, wp_posts.post_status and the status column in wc_order_stats are all varchar(20). The prefixed slug has to fit, so a custom status slug gets 17 characters. The orders table warns through wc_doing_it_wrong(); the analytics table truncates silently, which is how two long statuses end up sharing a row in a revenue report.

The valid-status filters decide what the customer can still do

Appearing in the dropdown says nothing about what the order is allowed to do. That is a separate family of filters, each one guarding one customer or admin action, and each one defaulting to a short list that a custom status is not in. The name of each filter is the action it gates.

FilterDefault statusesWhat it gates
woocommerce_valid_order_statuses_for_payment pending, failed Whether the order page offers payment again, and what needs_payment() returns.
woocommerce_valid_order_statuses_for_payment_complete on-hold, pending, failed, cancelled Whether payment_complete() accepts the order at all.
woocommerce_valid_order_statuses_for_cancel pending, failed The customer-facing cancel button.
woocommerce_valid_order_statuses_for_order_again completed Whether the customer can reorder from the order.

An order in an unrecognised status falls out of all four lists, which is the mechanism behind the store owner's complaint. needs_payment() is the method to read when you want to know what the customer sees:

<?php
$statuses = apply_filters( 'woocommerce_valid_order_statuses_for_payment', array( 'pending', 'failed' ), $order );
return apply_filters( 'woocommerce_order_needs_payment', $order->has_status( $statuses ) && $order->get_total() > 0, $order, $statuses );

So the fix for "the customer cannot pay this order" is one filter, and it should be narrow. Adding every custom status to the payment list means a cancelled order offers to take money again.

<?php
add_filter(
	'woocommerce_valid_order_statuses_for_payment',
	function ( $statuses, $order ) {
		if ( $order->has_status( 'sifero-packed' ) && ! $order->get_date_paid() ) {
			$statuses[] = 'sifero-packed';
		}
		return $statuses;
	},
	10,
	2
);

Adding a status to the paid list is a payment decision, not a label

wc_get_is_paid_statuses() returns array( 'processing', 'completed' ) and passes it through the woocommerce_order_is_paid_statuses filter. That list has one job: it is WooCommerce's answer to "has this order been paid", and a lot of code asks. WC_Abstract_Order::is_paid() checks the list, woocommerce_order_status_changed compares the new status against it before firing the payment hook, the customer-lookup tables use it to decide which orders count as a customer's revenue, and the invoice email branches on it.

Adding your status to the list says every one of those should now treat the order as paid. If the status means "money has arrived, we are still packing", that is a fair claim. If it means "the parcel is on the van", it is not, and the effects are not cosmetic: the order starts counting as paid revenue for the customer, the invoice email stops prompting for payment, and the payment hook fires when the order enters your status from pending.

<?php
add_filter(
	'woocommerce_order_is_paid_statuses',
	function ( $statuses ) {
		$statuses[] = 'sifero-packed';
		return $statuses;
	}
);

Two things follow that are easy to get wrong. First, the list holds unprefixed slugs, so add sifero-packed and not wc-sifero-packed; a prefixed entry matches nothing. Second, this list and the analytics exclusion list are not the same thing. Analytics excludes pending, cancelled and failed by default, so a custom status that nobody excludes already counts towards revenue reports without you touching wc_get_is_paid_statuses() at all. Decide both questions deliberately, and they are different questions.

Lab 40 minutes

Build a small plugin that registers a sifero-invoiced status, put one order into it, and then fix the four things that are wrong with it. Write each fix as its own filter so you can turn them on one at a time.

  1. Register the status with both register_post_status() and wc_order_statuses. Put it in a plugin file under wp-content/mu-plugins/ and hook the registration to init at priority 9: wp eval-file runs after init has already fired, so a registration added from the script itself never runs.
  2. Move a pending order into the status and print the raw stored value from the database. Confirm it carries the wc- prefix.
  3. Print needs_payment(), is_paid() and the result of wc_get_orders( array( 'status' => 'sifero-invoiced' ) ) for that order.
  4. Now add the status to woocommerce_valid_order_statuses_for_payment and print needs_payment() again.
  5. Add it to woocommerce_order_is_paid_statuses and print is_paid() again.

Then, before looking at the solution, delete the register_post_status() call while leaving the filter in place, move another order into the status, and write down what the raw stored value is now and which of your three reads changed.

Show the solution and the reasoning

Here is the plugin, with each decision in its own filter so the ordering of the checks is visible. It registers on init at priority 9 to sit alongside WooCommerce's own registration rather than ahead of it.

<?php
/**
 * Plugin Name: Sifero Invoiced Status
 */

add_action(
	'init',
	function () {
		register_post_status(
			'wc-sifero-invoiced',
			array(
				'label'                     => 'Invoiced',
				'public'                    => false,
				'exclude_from_search'       => false,
				'show_in_admin_all_list'    => true,
				'show_in_admin_status_list' => true,
				'label_count'               => _n_noop( 'Invoiced <span class="count">(%s)</span>', 'Invoiced <span class="count">(%s)</span>' ),
			)
		);
	},
	9
);

add_filter(
	'wc_order_statuses',
	function ( $statuses ) {
		$statuses['wc-sifero-invoiced'] = 'Invoiced';
		return $statuses;
	}
);

add_filter(
	'woocommerce_valid_order_statuses_for_payment',
	function ( $statuses, $order ) {
		if ( $order->has_status( 'sifero-invoiced' ) && ! $order->get_date_paid() ) {
			$statuses[] = 'sifero-invoiced';
		}
		return $statuses;
	},
	10,
	2
);

add_filter(
	'woocommerce_order_is_paid_statuses',
	function ( $statuses ) {
		$statuses[] = 'sifero-invoiced';
		return $statuses;
	}
);

The story those five checks tell is the lesson. Steps two to five are a progression: the status stores correctly with both registrations in place, then needs_payment() and is_paid() both come back false, and each filter flips exactly one of them. The reason the store owner's two complaints arrived together is that the two questions are asked in different places, and neither code path knows about the other.

Removing register_post_status() while keeping the filter produces the third row of the table: the wc- prefix disappears from the stored value, needs_payment() still answers as your filters say, and the change that matters is the one you cannot see from the order object. Any query that filters on the prefixed slug now matches nothing, and on the posts-based store the query loses its status clause entirely and returns every order.

Two decisions I would defend in review, and one I would not. The payment filter is scoped with ! $order->get_date_paid(), so an order that has already been paid does not advertise itself as payable again; without that check, a customer who lands on the order page twice can pay twice unless the gateway blocks it. The paid-list entry is deliberate, because "invoiced" means money arrived. What I would not do is add the status to woocommerce_valid_order_statuses_for_cancel as well: the cancel button is how a customer stops an order, and a status meaning "we have taken payment for this" is the wrong side of that line.

There is a limit worth naming. None of these filters control the admin's status dropdown in the order screen, and none of them stop a store manager from setting any status that is in wc_get_order_statuses() on any order. If your status is supposed to be reachable only in one direction, the enforcement belongs in a transition listener, not in the vocabulary.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.