Skip to content

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:

npx @sadrmanesh/commerce-agent-skills add woocommerce-plugin-development

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

  1. Declare the dependency in the main plugin file. WordPress 6.5+ understands Requires Plugins: woocommerce. Set WC tested up to as well. WooCommerce only surfaces High-Performance Order Storage (HPOS) compatibility in the plugins list when that header is present.
  2. Stop direct requests with defined( 'ABSPATH' ) || exit;.
  3. Read and write products and orders through CRUD. Load with wc_get_product() or wc_get_order(), change setters, then call save() once. List orders with wc_get_orders(). Do not query wp_posts for shop_order.
  4. From the main plugin file, on before_woocommerce_init, declare feature compatibility. Pass __FILE__ from that main file. Declare custom_order_tables only when every order read and write uses CRUD or OrderUtil. Declare cart_checkout_blocks only when checkout behavior also runs for the Checkout block.
  5. Hook the specific action. Cover both checkout stacks when the code runs during checkout. Open the hook notes before choosing one.
  6. Authorize every write with a capability and a nonce. A nonce is not authentication.
  7. 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_order receives the WC_Order and 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_request and woocommerce_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 Domain header.
  • Print with esc_html__(), esc_attr__(), and esc_html_e(). Use sprintf placeholders. 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() on init. 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 with wc_create_refund(). Flipping the parent status to refunded does 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_reduced inside wc_maybe_reduce_stock_levels().
  • woocommerce_checkout_update_order_meta still runs. Examples that call update_post_meta() inside it are not HPOS-safe.
  • Declaring custom_order_tables compatibility true is a promise. If any order path still uses post APIs, declare false until that path is gone.
  • SQL against {$wpdb->prefix}wc_orders skips caches and the posts fallback. Limit it to a measured report, and branch with OrderUtil::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.