WooCommerce plugin development¶
woocommerce-plugin-development
Build and change WooCommerce plugins with hooks, CRUD data stores, HPOS-safe order access, nonces, capabilities, and gettext. Use when writing or reviewing a plugin that reads or writes WooCommerce products, orders, customers, or checkout.
Version 0.3.2 · MIT · Source · Rule
Install this skill
Copy the skill and its rule into your project:
The skill goes to .agents/skills/ and the rule to .agents/rules/. See Command line for other directories.
Use this skill for a WordPress plugin that depends on WooCommerce. Use the REST API skill for an external HTTP client. Use the payment and webhook skills for capture and notification handling. Use the WordPress plugin security skill for the authorization details of any new endpoint.
Procedure¶
- Declare the dependency in the main plugin file. WordPress 6.5+ understands
Requires Plugins: woocommerce. SetWC tested up toas well. WooCommerce only surfaces High-Performance Order Storage (HPOS) compatibility in the plugins list when that header is present. - Stop direct requests with
defined( 'ABSPATH' ) || exit;. - Read and write products and orders through CRUD. Load with
wc_get_product()orwc_get_order(), change setters, then callsave()once. List orders withwc_get_orders(). Do not querywp_postsforshop_order. - From the main plugin file, on
before_woocommerce_init, declare feature compatibility. Pass__FILE__from that main file. Declarecustom_order_tablesonly when every order read and write uses CRUD orOrderUtil. Declarecart_checkout_blocksonly when checkout behavior also runs for the Checkout block. - Hook the specific action. Cover both checkout stacks when the code runs during checkout. Open the hook notes before choosing one.
- Authorize every write with a capability and a nonce. A nonce is not authentication.
- Send user-facing strings through gettext with the plugin text domain.
A skeleton header and the compatibility declaration are in assets/main-plugin-file.php. Copy it into a new plugin only after you delete the declaration you cannot yet keep.
Order and product writes¶
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
$order->update_meta_data( '_carrier_code', sanitize_text_field( $carrier ) );
$order->save();
Do not call update_post_meta(), get_post_meta(), wp_insert_post(), or get_posts() for orders. Those use the posts tables. Under HPOS the order may be absent there, or the row may be a stale compatibility copy.
HPOS is the default for new installations from WooCommerce 8.2 onward. Older shops may still use posts storage. CRUD serves both. Compatibility mode can mirror orders into posts; code that reads those posts still fails when a merchant turns the mirror off.
Open the HPOS notes before adding an admin order screen, a custom order query, or SQL.
Checkout hooks¶
Classic shortcode checkout and the Checkout block do not share one hook.
- Classic checkout, before the first save:
woocommerce_checkout_create_orderreceives theWC_Orderand the posted data. - Classic checkout, after the order exists:
woocommerce_checkout_order_processed. - Checkout block and Store API:
woocommerce_store_api_checkout_update_order_from_requestandwoocommerce_store_api_checkout_order_processed.
A plugin that hooks only the classic actions does not run during block checkout.
Admin order screens¶
wc_get_page_screen_id( 'shop-order' ) returns shop_order on posts storage and the HPOS orders screen id otherwise. Use it for metaboxes. The callback may receive a WP_Post or a WC_Order. Normalize with wc_get_order() and ignore the original argument after that. Link to an order with $order->get_edit_order_url().
save_post_shop_order does not run for HPOS orders. Persist admin fields from woocommerce_process_shop_order_meta, then wc_get_order() and save().
Internationalization¶
- The text domain matches the plugin slug. Set the
Text Domainheader. - Print with
esc_html__(),esc_attr__(), andesc_html_e(). Usesprintfplaceholders. Do not concatenate translated fragments. - Language packs for plugins hosted on WordPress.org load from the slug. For any other distribution, call
load_plugin_textdomain()oninit. WordPress 6.7 reports a notice when translations load earlier. - RTL layout is a separate task. Use the Persian and RTL skill when the storefront needs it.
Gotchas¶
save()writes the object. Set the fields for this change, then save once.- Refunds are orders of type
shop_order_refund. Create them withwc_create_refund(). Flipping the parent status torefundeddoes not record a refund. - Stock reduction is tied to payment completion and status transitions. A second decrement double-counts. Core tracks this with
_order_stock_reducedinsidewc_maybe_reduce_stock_levels(). woocommerce_checkout_update_order_metastill runs. Examples that callupdate_post_meta()inside it are not HPOS-safe.- Declaring
custom_order_tablescompatibilitytrueis a promise. If any order path still uses post APIs, declarefalseuntil that path is gone. - SQL against
{$wpdb->prefix}wc_ordersskips caches and the posts fallback. Limit it to a measured report, and branch withOrderUtil::custom_orders_table_usage_is_enabled().
Verification¶
- With HPOS on and compatibility mode off, create and edit an order through the plugin. The change is visible in admin and in
wc_get_order(). - If both checkouts are enabled, place one classic order and one block order.
- A user without the required capability cannot perform the write. A missing or reused nonce is rejected.
- New strings are escaped where printed and are picked up by
wp i18n make-pot.
Evaluation¶
Worked examples and the checks they must satisfy are in evals/tasks.json.
References¶
WooCommerce hooks¶
Confirm argument order in the WooCommerce version you are running when a signature is load-bearing. Prefer a hook that receives a WC_Order or WC_Product. If a hook passes only an id, load the object with wc_get_order() or wc_get_product() and do not touch post meta.
Orders¶
| Hook | Use it for |
|---|---|
woocommerce_checkout_create_order |
Set order fields and meta during classic checkout, before the first save. |
woocommerce_checkout_order_processed |
React after a classic checkout order exists. |
woocommerce_store_api_checkout_update_order_from_request |
Same moment as classic create, for the Checkout block. |
woocommerce_store_api_checkout_order_processed |
React after a block checkout order exists. |
woocommerce_new_order |
Notice that an order id exists. Status may still be pending or checkout-draft. |
woocommerce_order_status_changed |
Compare previous and next status. Arguments are order id, old status, new status, and the order. |
woocommerce_payment_complete |
Run side effects after a verified payment. Do not use this as the only stock decrement. |
woocommerce_order_refunded |
React after wc_create_refund() has created the refund order. |
woocommerce_before_order_object_save |
Inspect or adjust an order about to be written through CRUD. |
woocommerce_process_shop_order_meta |
Save admin order fields for both storage modes. |
Do not use save_post_shop_order or manage_edit-shop_order_columns as the only integration point. The HPOS list table uses woocommerce_shop_order_list_table_columns and woocommerce_shop_order_list_table_custom_column. Register the custom-column action with two accepted arguments. The second is a WC_Order, not a post id.
Products¶
| Hook | Use it for |
|---|---|
woocommerce_before_product_object_save / woocommerce_after_product_object_save |
Changes made through WC_Product::save(). |
woocommerce_new_product / woocommerce_update_product |
After a product id exists. |
woocommerce_product_options_general_product_data |
Extra fields on the product data panel. |
woocommerce_process_product_meta |
Save those fields. Check a nonce and edit_products. |
woocommerce_product_set_stock / woocommerce_variation_set_stock |
React to a stock write. Do not write stock again from the same hook. |
Cart and checkout fields¶
| Hook | Use it for |
|---|---|
woocommerce_add_to_cart_validation |
Reject a line before it enters the cart. Return false and add a notice. |
woocommerce_before_calculate_totals |
Change line prices for this request. Guard with a flag so the hook does not recurse. |
woocommerce_cart_calculate_fees |
Add a fee. Fees are not a substitute for a payment surcharge unless the store shows the fee before the pay button. |
woocommerce_checkout_fields |
Add, remove, or relabel classic checkout fields. |
woocommerce_default_address_fields |
Change address fields for every country, then override per country with woocommerce_get_country_locale. |
Registration¶
Register gateways on woocommerce_payment_gateways. Register settings on woocommerce_get_settings_{tab}.
Run WooCommerce callbacks after WooCommerce has loaded. plugins_loaded at the default priority is too early if your plugin file loads before WooCommerce. Prefer woocommerce_loaded, or a later plugins_loaded priority, before calling wc_get_order() during bootstrap.
HPOS order access¶
High-Performance Order Storage keeps orders in dedicated tables:
{prefix}wc_orders{prefix}wc_order_addresses{prefix}wc_order_operational_data{prefix}wc_orders_meta
Posts storage keeps the same objects in wp_posts and wp_postmeta as the shop_order post type. Refunds use shop_order_refund in both modes. CRUD hides that choice. Direct SQL and post APIs do not.
Detect the active store¶
use Automattic\WooCommerce\Utilities\OrderUtil;
$hpos = class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled();
Use this to branch SQL or admin screen registration. Do not use it to decide whether wc_get_order() works. That function works in both modes.
Declare compatibility¶
Call this from the main plugin file. declare_compatibility() returns false outside before_woocommerce_init.
add_action( 'before_woocommerce_init', function () {
if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
}
} );
If the declaration lives in another file, pass the main plugin file path, such as example-plugin/example-plugin.php, instead of __FILE__.
Queries¶
$results = wc_get_orders(
array(
'status' => array( 'processing', 'completed' ),
'limit' => 50,
'paginate' => true,
'return' => 'objects',
)
);
Filter custom meta with meta_query only when the cardinality is small enough to measure. There is no unique index on arbitrary meta keys. An idempotency lookup by meta is not a uniqueness constraint.
Identify an order with OrderUtil::is_order() and OrderUtil::get_order_type(). Do not call get_post_type() for this.
Admin metabox¶
add_action( 'add_meta_boxes', function () {
if ( ! function_exists( 'wc_get_page_screen_id' ) ) {
return;
}
add_meta_box(
'example_order_panel',
__( 'Shipping desk', 'example-plugin' ),
'example_render_order_panel',
wc_get_page_screen_id( 'shop-order' ),
'side'
);
} );
function example_render_order_panel( $post_or_order ) {
if ( ! current_user_can( 'edit_shop_orders' ) ) {
return;
}
$order = $post_or_order instanceof WC_Order ? $post_or_order : wc_get_order( $post_or_order->ID ?? 0 );
if ( ! $order ) {
return;
}
wp_nonce_field( 'example_order_panel', 'example_order_nonce' );
echo esc_html( $order->get_order_number() );
}
Sync mode is not compatibility¶
While "Enable compatibility mode" is on, WooCommerce copies orders between the two stores. A plugin can appear to work because it reads the copy. Turn compatibility mode off in a staging shop and repeat the order flow before declaring custom_order_tables compatible.
Evaluation¶
Fixtures from evals/tasks.json. See evaluation fixtures for how the checks work.
save-order-meta¶
Store a carrier code from the WooCommerce order admin screen using CRUD so the value survives HPOS.
Must include: wc_get_order, update_meta_data, ->save(
Must not include: update_post_meta
Worked example
Load the order and write meta through CRUD.
$order = wc_get_order( $order_id );
$order->update_meta_data( '_carrier_code', $carrier );
$order->save();
Check edit_shop_orders and a nonce before this write.
declare-hpos¶
Declare High-Performance Order Storage compatibility from the main plugin file after order access uses CRUD.
Must include: before_woocommerce_init, declare_compatibility, custom_order_tables
Must not include: save_post_shop_order
Worked example
Declare compatibility on the WooCommerce init hook from the main plugin file.
add_action( 'before_woocommerce_init', function () {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
} );
Include a WC tested up to header set to the version you actually tested.