The WooCommerce architecture
The CRUD Layer, and Why You Must Use It
The tracking reference saved on an order is gone after a page reload. The metadata API is not the problem; skipping save() is, and the fix costs one line.
An extension stores a carrier's tracking reference on the order when the warehouse
scans the parcel, and it does that with update_meta_data(). The admin screen
shows the reference right up until the next request, when it is gone. The code looks
right, which is why this failure costs an afternoon.
The value was never written. update_meta_data() changes the order object
in memory and marks the metadata dirty. Writing to storage is a separate step that you
have to ask for, and the method that does it is save(). Everything in this
lesson follows from that split, because the split is deliberate and it is the reason
order updates can be made in one unit rather than several.
An order is an object you fetch, not a row you query
The entry point is wc_get_order(). Give it an order id and it returns a
WC_Order object, or false when no such order exists. It accepts
the order object you already have, which makes it harmless to call defensively.
<?php
$order = wc_get_order( 1042 );
if ( ! $order instanceof WC_Order ) {
return; // Fragment inside a function: no such order, so stop here.
}
$total = $order->get_total();
$email = $order->get_billing_email();
One constraint bites in unusual places: wc_get_order() calls
wc_doing_it_wrong() and returns false if you call it before the
woocommerce_after_register_post_type action has fired. On a normal request
that hook has long since run by the time your code executes. In a CLI script or a unit
test that bootstraps WordPress by hand, it has not, and you get a confusing
false rather than an error. Bootstrap further before fetching orders.
Once you have the object, the accessors are the API: get_billing_email(),
get_total(), get_date_created(), get_items(). Those
map to typed properties, and WooCommerce guarantees the mapping. There is no equivalent
guarantee for the database column, which is the theme of this lesson.
A property and a meta value are read by different methods
Two categories of data live on an order, and mixing up their accessors is the most common mistake in extension code.
Properties are the fields WooCommerce models itself. They have an accessor and, often,
a setter, and they are stored in a defined place: on HPOS in the columns of
wc_orders, wc_order_addresses and
wc_order_operational_data. Billing email is a property. So are totals,
currency, dates, the customer id and the payment method.
Meta is everything else. Your extension's own keys, plus individual custom fields an
integration adds. Meta goes through get_meta(), and it lands in the
wc_orders_meta table on HPOS or in wp_postmeta on the
posts-based data store.
<?php
// A property, in both directions.
$order->set_billing_email( 'ana@example.com' );
// Meta, in both directions.
$reference = $order->get_meta( '_sifero_tracking_reference' );
$order->update_meta_data( '_sifero_tracking_reference', 'SI8392011' );
There is a trap hiding in that first pair. get_meta() checks whether the
key is an internal one, and if a matching accessor exists it delegates to that accessor
instead of reading the meta table. So $order->get_meta( '_billing_email' )
works and returns the property, while
get_post_meta( $order_id, '_billing_email', true ) returns an empty string on
HPOS. The leading underscore does not make a key meta, and the prefix does not make it a
property. What makes it a property is that WooCommerce models it.
The undersized answer to a missing value
get_meta() returns an empty string for a key that is not set, which is
the same value it returns for a key that is set to an empty string. When the difference
matters, metadata_exists( 'order', $order_id, $key ) tells you whether the
row is there at all. Do not reach for get_post_meta() to disambiguate;
that read is the failure this module exists to prevent.
update_meta_data marks the object dirty, and save is what writes it
This is the mechanism behind the disappearing tracking reference, and it is worth seeing as three states rather than one call.
When you call update_meta_data(), the object stores the new value in its
own in-memory list of meta and marks that entry as changed. Nothing has touched the
database. If the request ends here, the value is discarded along with the object, which
is exactly what a page reload does.
Calling save() hands the object to its data store, which writes the
changed properties and flushes the dirty metadata rows. It is one call for the order as a
whole, which is the useful part: a status change and the note explaining it land together
or not at all.
<?php
$order = wc_get_order( 1042 );
$order->update_meta_data( '_sifero_tracking_reference', 'SI8392011' );
$order->save();
// A second change in the same request needs its own save().
$order->update_meta_data( '_sifero_carrier', 'sifero-courier' );
$order->save();
Two related methods behave the same way. add_meta_data() adds a row rather
than replacing the value under that key, so the key ends up with two rows and
get_meta() returns one of them. Pass true as its third argument
and it deletes the existing rows first, which makes it behave like a replace. That is
occasionally what you want for a log and almost never what you want for a reference.
delete_meta_data( $key ) marks the row for deletion, in memory, until a
save() follows.
get_status is the order status, and the post status is something else
WooCommerce once stored the order status as a post status with a wc-
prefix, and traces of that are all over older code and forum answers.
WC_Order::get_status() now returns the status without the prefix:
processing, not wc-processing.
That value is the one to compare against, and the one to pass to a hook name.
get_post_status( $order_id ) reads the WordPress post record instead, which
on HPOS is the placeholder row and has nothing to do with the order's state. Code that
gates on get_post_status() either never fires or fires for the wrong orders,
and it does so without an error.
<?php
$order = wc_get_order( 1042 );
$order->get_status(); // 'processing'
$order->has_status( 'processing' ); // true
get_post_status( $order->get_id() ); // the placeholder post's status
Changing status has two forms and they are not interchangeable.
set_status( $status ) records the change on the object, and the transition
hooks and customer emails fire when the object is saved.
update_status( $status, $note, $manual ) is the convenience wrapper: it sets
the status, optionally attaches a note, and saves in one call. Reach for
update_status() when you are changing status and nothing else. Reach for
set_status() plus your own save() when you are changing status
and metadata together, so both land in the same write.
Underneath, a data store does the reading and writing
The object you hold does not know SQL. It delegates to a data store, chosen by
WooCommerce at load time, and that choice is what the storage check from the first lesson
is really reporting. The posts-based data store is
WC_Order_Data_Store_CPT. The HPOS one lives in the
Automattic\WooCommerce\Internal\DataStores\Orders namespace and is not part
of the public extension API.
You can ask which class is in play without depending on either name:
<?php
$store = WC_Data_Store::load( 'order' );
echo $store->get_current_class_name();
That class name is diagnostic output, not something to branch on. Branching on a class
name is how an extension ends up broken by a WooCommerce refactor that renamed a class
without changing any behaviour you cared about. Branch on
OrderUtil::custom_orders_table_usage_is_enabled() when you genuinely need to
know, and otherwise write code that does not care.
The naive call and the supported call, side by side
Every row below is a pattern still circulating in tutorials, and every one of them is either silent on HPOS or actively wrong. The middle column is what to write instead.
| Naive access | Supported call | What breaks on HPOS |
|---|---|---|
get_post_meta( $id, '_billing_email', true ) |
$order->get_billing_email() |
No postmeta row exists, so the read returns an empty string and the address renders blank. |
update_post_meta( $id, $key, $value ) |
$order->update_meta_data( $key, $value ) then $order->save() |
The value lands in wp_postmeta, where get_meta() on the order does not look. The write appears to succeed and the value is invisible. |
get_post_status( $id ) |
$order->get_status() |
Reads the placeholder post, so the result has no relationship to the order's status. |
wp_update_post( array( 'ID' => $id, 'post_status' => 'wc-completed' ) ) |
$order->update_status( 'completed' ) |
Nothing about the order changes, and none of the transition hooks, notes or emails fire. |
get_posts( array( 'post_type' => 'shop_order' ) ) |
wc_get_orders( array( 'limit' => 20 ) ) |
On HPOS the orders table is not the posts table, so the query returns placeholders or nothing. |
get_post( $id )->post_date |
$order->get_date_created() |
Reads a date WordPress wrote for the placeholder, not the order's creation date. |
The pattern in all six rows is the same: the naive call reaches past the object for a value the object already knows how to produce, and the object is the only thing that knows which storage it is on. The database read itself is the bug, before any version of WooCommerce is involved.
Write a function that stores a tracking reference on an order and a companion function that reads it back, both inside a plugin file you activate, and drive them from a WP-CLI one-liner. Use an order id that exists on your test store.
wp eval 'var_dump( sifero_set_tracking( 1042, "SI8392011" ) );'
wp eval 'var_dump( sifero_get_tracking( 1042 ) );'
The setter must return true when it stored the value and
false when it could not, so the caller has something to check. Then run
the reader in a fresh process, which is the part that catches the missing
save().
When it works, write down three sentences: what the order object holds straight
after update_meta_data(), what the data store holds at that moment, and
which call closes the gap. Then delete your save() line, rerun both
commands, and record what the reader returns. That last value is what the support
ticket shows.
Show the solution and the reasoning
The interesting decisions here are the guard clauses and the return values, not the three CRUD calls in the middle.
<?php
/**
* Plugin Name: Sifero Tracking Reference
* Requires Plugins: woocommerce
*/
function sifero_set_tracking( int $order_id, string $reference ): bool {
$order = wc_get_order( $order_id );
if ( ! $order instanceof WC_Order ) {
return false;
}
$reference = sanitize_text_field( $reference );
if ( '' === $reference ) {
return false;
}
$order->update_meta_data( '_sifero_tracking_reference', $reference );
$order->save();
return true;
}
function sifero_get_tracking( int $order_id ): string {
$order = wc_get_order( $order_id );
if ( ! $order instanceof WC_Order ) {
return '';
}
return (string) $order->get_meta( '_sifero_tracking_reference' );
}
The type guard comes first. wc_get_order() returns
false for an id that does not exist, and calling
update_meta_data() on a boolean is a fatal error in PHP 8. The
instanceof check is also what makes the function safe to call with an id
that came from a URL parameter.
Sanitise at the boundary where data enters. The reference comes from
a warehouse scan or an admin field, both of which are input. Sanitising here means
every later read can treat the value as text. Note that the guard on the sanitised
value rather than the raw one is deliberate: a reference of " " is empty
after sanitising and should not be stored as a real value.
The missing save is the whole exercise. With
update_meta_data() and no save(), the setter would still
return true, because the object accepted the value. The reader in a second
process would return ''. That is the failure the lesson opened with, and
the reason the setter has to earn its return value: a function that reports success
while writing nothing is worse than one that throws.
Returning a string from the reader.
get_meta() returns an empty string for a key that is not set and can
return other types for keys WooCommerce stores differently, so casting to
string gives callers one predictable type. If you need to distinguish
"never set" from "set to empty", check
metadata_exists( 'order', $order_id, '_sifero_tracking_reference' ) before
reading. Do not solve that problem with get_post_meta(), which returns the
empty string for both cases on HPOS even when the meta row exists.
One alternative is worth considering and usually wrong: put the reference in the
order's own property set rather than in meta, by modelling it as a first-class field. If
the value needs to appear in reports, be queried in bulk, or outlive your extension,
meta in wc_orders_meta is a reasonable home. If it is a property of the
order that other code will want to read, a custom table or a proper field is a cleaner
long-term answer, at the cost of more code now. Meta is the pragmatic default and the
right first choice for a tracking reference.
Progress
Mark this lesson done and it stays marked in this browser.