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.
| Filter | register_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.
| Filter | Default statuses | What 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.
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.
- Register the status with both
register_post_status()andwc_order_statuses. Put it in a plugin file underwp-content/mu-plugins/and hook the registration toinitat priority 9:wp eval-fileruns afterinithas already fired, so a registration added from the script itself never runs. - Move a pending order into the status and print the raw stored value from the
database. Confirm it carries the
wc-prefix. - Print
needs_payment(),is_paid()and the result ofwc_get_orders( array( 'status' => 'sifero-invoiced' ) )for that order. - Now add the status to
woocommerce_valid_order_statuses_for_paymentand printneeds_payment()again. - Add it to
woocommerce_order_is_paid_statusesand printis_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.