Skip to content
System nominalUTC --:--:--Articles 085
MODULE 04 Lesson 12 / 18 40 MIN

Building a plugin by prompt

WooCommerce Data and the Hooks That Matter

Inventing a WooCommerce hook is the most common agent error in this area. How to check a hook exists, what data each hands you, and where orders live.

WordPress invented-hook errors have a symptom you can see: the page is unchanged. WooCommerce invented-hook errors have the same symptom with money attached. A checkout filter attached to a hook that does not exist simply never runs, the order goes through at the wrong total, and nothing in the logs says a word. The customer sees one price and the invoice says another.

The reason agents invent WooCommerce hooks more often than WordPress ones is volume. WooCommerce has hundreds of them, they are named inconsistently, several have been renamed across major versions, and the useful ones for a task are rarely the ones with the most obvious names. There are also two checkout implementations, and the hook that fires in one does not fire in the other.

So the method is to verify before you attach. Everything below was run against WooCommerce 11.2.1 on WordPress 7.0.7, with the source tree open beside the running install.

The source tree is the register, and it is countable

The first fact worth having is the size of the surface. Two commands over an extracted WooCommerce tree produce the names, and they take a second:

$ grep -rho "do_action( *'[a-z0-9_]*'" --include=*.php . | sed "s/do_action( *'//;s/'//" | sort -u | wc -l
864
$ grep -rho "apply_filters( *'[a-z0-9_]*'" --include=*.php . | sed "s/apply_filters( *'//;s/'//" | sort -u | wc -l
1723

That is 864 distinct action names and 1,723 distinct filter names in one release, and it is a count rather than a claim. Now keep the list and check a candidate against it, which is the thing an agent skips:

$ grep -rn --include=*.php "'woocommerce_checkout_create_order_line_item'" . | head -2
./includes/class-wc-checkout.php:611:	do_action( 'woocommerce_checkout_create_order_line_item', $item, $cart_item_key, $values, $order );
$ grep -rn --include=*.php "'woocommerce_after_order_object_save'" . | head -2
$ echo "exit $?"
1

The first hook exists, the line number tells you its arguments in order, and the file tells you which code path fires it. The second hook returns nothing. Do that grep before attaching a callback and most invented-hook bugs never reach a site.

A hook can be built from a variable and still be real

The grep is necessary and not sufficient, and this is the correction that matters. woocommerce_after_order_object_save is real. My first search for it found nothing and I was wrong, because the hook is assembled at runtime:

// wp-content/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
do_action( 'woocommerce_before_' . $this->object_type . '_object_save', $this, $this->data_store );
...
do_action( 'woocommerce_after_' . $this->object_type . '_object_save', $this, $this->data_store );

For a WC_Order, $this->object_type is order, so the hook is woocommerce_after_order_object_save, with two arguments: the object and the data store. For a product it is woocommerce_after_product_object_save. The same pattern covers woocommerce_product_get_price, which comes from 'woocommerce_' . $this->object_type . '_get_' . $prop in get_prop(), with woocommerce_product_variation_get_ as the variation prefix.

Two consequences. A text search can produce false negatives, so grep tells you a hook exists but never that it does not. And the run-time check is what settles it, which is the next section.

Count what actually fires

Attach a counting callback to every hook in your list, then exercise the code path and print the tally. On a real checkout that produces a table where the real hooks have small integers beside them and the invented ones have zero, and both halves are evidence. This is how you check a hook you cannot find by name.

A real order, and which hooks fired

I built a bookkeeping plugin that attaches a counter to thirteen hook names: ten real candidates and three invented ones. Then I created a product, filled a cart, ran WC_Checkout::create_order() with billing details, and called payment_complete() on the result. One process, one order, and the tally:

woocommerce_checkout_create_order                    1
woocommerce_checkout_update_order_meta               1
woocommerce_checkout_create_order_line_item          1
woocommerce_checkout_order_created                   1
woocommerce_checkout_order_processed                 0
woocommerce_store_api_checkout_update_order_meta     0
woocommerce_new_order                                1
woocommerce_update_order                             2
woocommerce_order_status_changed                     1
woocommerce_order_object_updated_props               6
woocommerce_order_created                            0
woocommerce_after_order_object_save                  3
woocommerce_checkout_save_order_meta                 0

Three readings of that table are worth your time.

First, the zeros at the bottom are invented names, and they behave as invented names should. woocommerce_order_created is the name most agents reach for and it has never existed; the real one is woocommerce_new_order. woocommerce_checkout_save_order_meta is the other obvious guess, and the real one is woocommerce_checkout_update_order_meta. Both fakes sit one word away from something that works, which is exactly why they are dangerous.

Second, woocommerce_checkout_order_processed is zero, and that is not a bug in the plugin or the test. It fires from process_checkout(), and I called create_order() directly, so the code path never reached it. A hook exists and does not fire, and the only way to know which is to exercise the path. Agents read this the other way round constantly: "it is in the docs, therefore it runs".

Third, the Store API hooks are all zero because this was the classic checkout. woocommerce_store_api_checkout_update_order_meta belongs to the block checkout, which runs through src/StoreApi/Routes/V1/Checkout.php. The Store API also passes a WC_Order object where the classic hook passes an integer ID:

// includes/class-wc-checkout.php - classic checkout
do_action( 'woocommerce_checkout_update_order_meta', $order_id, $data );

// src/StoreApi/Routes/V1/Checkout.php - block checkout
do_action( 'woocommerce_store_api_checkout_update_order_meta', $this->order );

If a modification works on a shortcode checkout page and not on a block checkout page, this difference is usually why. A plugin that needs to cover both attaches to both and reads each argument according to its own signature.

Pick the hook by what you need, not by what sounds right

The three order-creation hooks that fired are not interchangeable, and the argument list is the reason to choose between them.

HookFires whenWhat you get
woocommerce_checkout_create_order Immediately after the order object is created, before line items are added. The order and the posted checkout data. Use it to set order-level meta.
woocommerce_checkout_create_order_line_item Once per cart line, as the line item is built. Item, cart item key, cart item values, order. Use it to copy cart data onto the line.
woocommerce_checkout_order_created After the order and all its items exist. The order. Use it when you need the finished object, including totals.

An agent asked to "add a delivery note from the checkout form to the order" will often attach to all three, or to woocommerce_new_order, and the result works, wastes work and occasionally writes the same value twice. The question to ask is whether you need the order alone or the order plus the submitted data, because woocommerce_checkout_order_created hands you an order and no form fields. Anything that reads $_POST at that point is reading a request that may already have moved on.

The tie-breaker is the Shop API path: if the site uses block checkout, tie the work to the order object plus the woocommerce_store_api_checkout_update_order_meta hook, because in that flow the order may be updated more than once before payment and the classic hooks do not cover it.

Money filters and fees, where the bugs are expensive

Two hooks carry most of the custom pricing work, and both are easy to verify by watching the number change. The price filter is generated dynamically and does not appear in a text search:

add_filter( 'woocommerce_product_get_price', 'lab_apply_surcharge', 20, 2 );

function lab_apply_surcharge( $price, $product ) {
	if ( ! $product instanceof WC_Product || '' === $price ) {
		return $price;
	}
	if ( ! has_term( 'surcharge', 'product_cat', $product->get_id() ) ) {
		return $price;
	}
	return (string) ( (float) $price * 1.10 );
}

With a 19.00 product in the surcharge category, get_price() returned 20.9. With the same filter removed, it returned 19.00. That is the entire test, and it takes one command. The cart fee hook is the same shape and a different mechanism:

add_action( 'woocommerce_cart_calculate_fees', 'lab_add_handling_fee' );

function lab_add_handling_fee( $cart ) {
	$cart->add_fee( 'Handling', 2.50, true );
}

With one 19.00 product in the cart the totals came back as subtotal 19, a fee entry named handling with amount 2.5, and a cart total of 21.50. Notice that the fee is added through $cart->add_fee() rather than by writing to a property. An agent that assigns to $cart->fees directly will appear to work in one request and lose the fee at the next recalculation, because calculate_fees() fires the hook and expects the cart's own API to be the only writer.

Orders do not necessarily live in posts

The last piece of data knowledge is structural, and it changes what "read the order meta" means. WooCommerce has two order storage implementations: the legacy one that stores orders as shop_order posts with postmeta, and High-Performance Order Storage, which stores them in wp_wc_orders and wp_wc_orders_meta. On my install HPOS was off, the plugin was created, and then I enabled it:

$ wp wc hpos sync --user=admin
Success: Custom order tables were created successfully.
Success: 1 order was synced in 0 seconds.
$ wp wc hpos enable --user=admin
Success: HPOS enabled.

$ wp db query "SELECT COUNT(*) FROM wp_wc_orders;"
2
$ wp db query "SELECT COUNT(*) FROM wp_posts WHERE post_type='shop_order';"
1

Two orders in the custom table, one post left behind from the sync. The order created after HPOS was enabled went to the custom table and was readable through wc_get_order() with its items and totals intact. A plugin that queries wp_posts for orders, or calls update_post_meta() on an order ID, is correct on one configuration and wrong on the other, and WooCommerce ships a compatibility API precisely because that mistake is common:

use Automattic\WooCommerce\Utilities\OrderUtil;

if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
	// read through the order object, or query the custom tables
}

For an agent, the instruction that prevents this class of bug is short and worth putting in your AGENTS.md: never query wp_posts or wp_postmeta for orders and never write order data with post functions; use wc_get_order(), the WC_Order setters, and wc_get_orders(). The Agentic toolkit Automattic publishes for WooCommerce work names HPOS declaration and dual-table compatibility among its skills, which is a useful signal about where the sharp edges are.

What I did not do in this lesson is complete a payment through a real gateway or run anything through the block checkout front end over HTTP. The hook tally comes from calling the classic checkout API in a WP-CLI process, and the Store API rows are zero because that path never ran. Treat the Store API signature as read from source and the classic one as observed.

Lab 40 minutes

Verify three hooks before writing a line against them, then prove one of them fires.

Pick a real task you would normally give an agent: copy a value from the cart to the order, change the displayed price conditionally, or add a fee. Write down the hook name you would have used without checking, then search the WooCommerce source for the exact name:

grep -rn --include=*.php "'woocommerce_YOUR_HOOK'" wp-content/plugins/woocommerce | head -3

If that returns nothing, search for the hook family rather than the name, because the real hook may be assembled from a variable: grep -rn --include=*.php "do_action( 'woocommerce_" includes/abstracts/ | head -20.

Now write a mu-plugin that counts the hooks you care about plus one you believe does not exist, exercise the code path once, and print the tally. Then answer: which of your three candidates fired, and for the ones that did not, is it because the hook does not exist or because your code path does not reach it?

Show the solution and the reasoning

The counting plugin is nine lines and does more than the search does, so it is worth keeping permanently in a development install:

foreach ( array( 'woocommerce_new_order', 'woocommerce_checkout_create_order',
	'woocommerce_checkout_order_created', 'woocommerce_order_created' ) as $hook ) {
	add_action( $hook, function () use ( $hook ) {
		$GLOBALS['lab_counts'][ $hook ] = ( $GLOBALS['lab_counts'][ $hook ] ?? 0 ) + 1;
	} );
}

Then exercise the path and print the array. On the classic checkout in this lesson, woocommerce_new_order, woocommerce_checkout_create_order and woocommerce_checkout_order_created each ended at 1 while woocommerce_order_created stayed at 0. Three of those four names are plausible English and only one is fake, which is the reason a name-based judgement fails and a counter does not.

The interesting part of the lab is the last question, because "did not fire" has two causes and they need different fixes. If the name is absent from the source, it is a fake and the fix is to find the real hook. If the name is in the source and did not fire, the hook is real and your code path does not reach it — which is what happened with woocommerce_checkout_order_processed in this lesson, where create_order() simply does not call process_checkout(). Attaching to a real hook that your site never triggers is the failure mode that looks exactly like success, because the code is correct and the behaviour is absent.

Three specific names are worth memorising as corrections, since they are the ones agents reach for: woocommerce_new_order rather than woocommerce_order_created; woocommerce_checkout_update_order_meta rather than woocommerce_checkout_save_order_meta; and on a block checkout, woocommerce_store_api_checkout_update_order_meta rather than either of them. The underlying principle is the reason this lesson exists in a course about agents rather than in a WooCommerce reference: a hook name is an API call, and an API call that does not exist fails without an error message.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

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