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.
| Hook | Fires when | What 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.
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.