Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 02 / 06 30 MIN

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 accessSupported callWhat 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.

Lab 30 minutes

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.