WooCommerce can be shaped into almost any store, but customizations made the wrong way are the reason many stores dread updating. The rule is simple: never edit WooCommerce's own files. Everything below works through hooks, template overrides in a child theme, or a small custom plugin, so updates stay routine.
Where custom code should liveLink to this section
- Store behaviour (pricing rules, extra fields, emails, integrations) goes in a custom plugin. It keeps working if you change themes.
- Presentation (markup changes, template overrides) goes in a child theme.
Avoid stacking ten snippet plugins. One well-organized custom plugin is easier to debug, version and hand over. The plugin development guide covers the structure and security basics.
Hooks: the first tool to reach forLink to this section
WooCommerce templates are full of action hooks, and most of its data passes through filters. A common example is adding a gift message field to a product, carrying it through the cart and saving it on the order.
1. Show the field on the product pageLink to this section
add_action( 'woocommerce_before_add_to_cart_button', function () {
?>
<p class="form-row">
<label for="gift_message"><?php esc_html_e( 'Gift message (optional)', 'acme' ); ?></label>
<textarea id="gift_message" name="gift_message" maxlength="200" rows="3"></textarea>
</p>
<?php
} );2. Save it with the cart itemLink to this section
add_filter( 'woocommerce_add_cart_item_data', function ( $cart_item_data ) {
if ( ! empty( $_POST['gift_message'] ) ) {
$cart_item_data['gift_message'] = sanitize_textarea_field( wp_unslash( $_POST['gift_message'] ) );
// Make otherwise identical items with different messages separate lines.
$cart_item_data['gift_key'] = md5( $cart_item_data['gift_message'] );
}
return $cart_item_data;
} );3. Display it in the cart and checkoutLink to this section
add_filter( 'woocommerce_get_item_data', function ( $item_data, $cart_item ) {
if ( isset( $cart_item['gift_message'] ) ) {
$item_data[] = array(
'key' => __( 'Gift message', 'acme' ),
'value' => esc_html( $cart_item['gift_message'] ),
);
}
return $item_data;
}, 10, 2 );4. Store it on the order line itemLink to this section
add_action( 'woocommerce_checkout_create_order_line_item', function ( $item, $cart_item_key, $values ) {
if ( isset( $values['gift_message'] ) ) {
$item->add_meta_data( __( 'Gift message', 'acme' ), $values['gift_message'], true );
}
}, 10, 3 );The message now appears in the admin order screen and in order emails, with no template edits at all. This pattern of capturing data, carrying it in the cart and saving it as line-item meta covers engraving text, delivery dates, gift wrap and many other personalization requests.
woocommerce_checkout_create_order_line_item runs for both the classic checkout and the block-based Checkout, so this pattern works in either.
Which hook for which jobLink to this section
A quick map of the hooks behind the most common store requests:
| Request | Hook | Type |
|---|---|---|
| Change a price on the fly (bulk or member discounts) | woocommerce_product_get_price |
filter |
| Add a fee or surcharge to the cart | woocommerce_cart_calculate_fees |
action |
| Hide a shipping method under conditions | woocommerce_package_rates |
filter |
| Change "Add to cart" text | woocommerce_product_single_add_to_cart_text |
filter |
| Act when an order is paid | woocommerce_payment_complete |
action |
| Act on any status change | woocommerce_order_status_changed |
action |
| Add content to order emails | woocommerce_email_order_meta |
action |
Two cautions: price filters run many times per page, so keep them fast and avoid database queries inside them. And don't run once-only tasks on the thank-you page, because customers can reload it or never reach it. Use woocommerce_payment_complete for online gateways, and remember that offline methods such as cash on delivery or bank transfer don't trigger it, so pair it with woocommerce_order_status_changed when those are enabled.
Template overrides: use sparingly, track versionsLink to this section
When a hook can't do it, copy the template from wp-content/plugins/woocommerce/templates/ into your-child-theme/woocommerce/ using the same path, and edit the copy.
Every template has a @version line in its header. When WooCommerce changes a template, your copy becomes outdated. WooCommerce → Status lists outdated overrides under "Templates". Check it after every major update and merge the changes, or your store can miss fixes, including security fixes.
Rules of thumb:
- Override the smallest template that does the job, never the whole
single-product.phpto move one element. - If you only need to move or remove something, use
remove_action()andadd_action()on the template's hooks instead of overriding.
// Move the price below the short description on single products.
remove_action( 'woocommerce_single_product_summary', 'woocommerce_template_single_price', 10 );
add_action( 'woocommerce_single_product_summary', 'woocommerce_template_single_price', 25 );The block-based checkout changes the rulesLink to this section
New stores now get the Cart and Checkout blocks by default. They are React-based and do not run the classic PHP checkout hooks such as woocommerce_checkout_fields or woocommerce_after_order_notes. Code written for the classic checkout silently does nothing there.
Your options:
- For extra checkout fields, use WooCommerce's Additional Checkout Fields API (
woocommerce_register_additional_checkout_field()), which works with the blocks and saves values to the order for you. - If a store depends on many classic checkout customizations, you can switch the Checkout page back to the classic shortcode while you migrate. Treat that as a temporary step.
Always check which checkout a store uses before estimating checkout work.
HPOS: write order code that works with custom tablesLink to this section
High-Performance Order Storage moves orders out of wp_posts and wp_postmeta into dedicated tables. It is the default for new stores and much faster for order-heavy shops. Code that reads orders with get_post_meta() or queries wp_posts for shop_order breaks when HPOS is on.
Use the WooCommerce CRUD API instead:
// ❌ Breaks with HPOS
$tracking = get_post_meta( $order_id, '_tracking_number', true );
// ✅ Works with both storage engines
$order = wc_get_order( $order_id );
$tracking = $order ? $order->get_meta( '_tracking_number' ) : '';
if ( $order ) {
$order->update_meta_data( '_tracking_number', sanitize_text_field( $number ) );
$order->save();
}Query orders with wc_get_orders() rather than WP_Query:
$orders = wc_get_orders( array(
'status' => array( 'wc-processing' ),
'date_created' => '>' . ( time() - DAY_IN_SECONDS ),
'limit' => 50,
) );Then tell WooCommerce your plugin is compatible, so the store owner isn't warned:
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 );
}
} );Adding a checkout field that works with the block checkoutLink to this section
Here is the Additional Checkout Fields API in practice: an optional delivery-instructions field in the order section of the Checkout block.
add_action( 'woocommerce_init', function () {
if ( ! function_exists( 'woocommerce_register_additional_checkout_field' ) ) {
return; // older WooCommerce: keep the classic implementation
}
woocommerce_register_additional_checkout_field( array(
'id' => 'acme/delivery-instructions',
'label' => __( 'Delivery instructions', 'acme' ),
'location' => 'order',
'type' => 'text',
'required' => false,
) );
} );The id needs a namespace (acme/…) so it can't clash with another plugin. The location can be contact, address or order, and the field types include text, select and checkbox. WooCommerce validates the field, stores the value with the order and shows it in the order admin screen and emails, so there's no separate save handler to write.
When a hook doesn't fireLink to this section
Most "my code does nothing" reports come down to a short list:
- Wrong checkout. Classic hooks such as
woocommerce_checkout_fieldsdon't run in the Checkout block. Check which one the page uses first. - Too early. WooCommerce functions aren't available until WooCommerce has loaded. Hook into
woocommerce_initorplugins_loadedrather than running code at the top of a file. - Missing argument count. A filter callback that needs three arguments must say so in
add_filter( …, 10, 3 ), or it only receives the first. - Page caching. Cart, checkout and My Account pages must never be served from a full-page cache, or customers see stale or someone else's data. Most hosts exclude them automatically; custom cache rules often don't.
- No visibility. Log instead of guessing.
wc_get_logger()->info( 'Gift message saved', array( 'source' => 'acme' ) );writes to WooCommerce → Status → Logs, where you can filter by source.
Integrations and the REST APILink to this section
For ERP, CRM or shipping integrations, the WooCommerce REST API (/wp-json/wc/v3/) and webhooks are usually cleaner than custom database syncing. When you debug a webhook payload or an API response, paste it into the JSON formatter to see the structure, and use the regex tester to check SKU or order-number patterns before relying on them in code.
Product pages also benefit from structured data (Product with offers). Most SEO plugins add it, and the schema generator helps you check what a valid block looks like.
Pre-update checklist for a customized storeLink to this section
- ☐ All custom code is in a plugin or child theme; WooCommerce files are untouched.
- ☐ WooCommerce → Status shows no outdated template overrides.
- ☐ Custom order code uses the CRUD API and declares HPOS compatibility.
- ☐ Checkout customizations match the checkout type in use (blocks or classic).
- ☐ Updates tested on staging: product page, cart, checkout, payment, emails.
- ☐ A fresh backup exists before updating production.
Frequently asked questionsLink to this section
Should customizations go in a child theme or a plugin?Link to this section
Behaviour goes in a plugin, presentation in a child theme. A pricing rule or an extra order field must keep working if the store changes theme; a moved price label doesn't need to.
How do I know whether HPOS is enabled?Link to this section
Go to WooCommerce → Settings → Advanced → Features. It shows the order storage in use and whether compatibility mode keeps the old tables in sync. Plugins that haven't declared compatibility are listed there too.
Do I still use get_post_meta() for products?Link to this section
HPOS only moves orders. Products are still posts, so get_post_meta() works, but $product->get_meta() and the other product CRUD methods are safer: they respect WooCommerce's caching and keep working if product storage changes in future.
Do template overrides work with block themes?Link to this section
Partly. In block themes, product, shop and cart layouts come from block templates edited in the Site Editor, so many PHP template overrides are never loaded. Check whether the store runs a classic or a block theme before planning template work.
For gift and personalized-product stores like G4Gift, the cart-item pattern above is the backbone of the shopping experience. Weighing WooCommerce against a hosted platform? See how Shopify's Online Store 2.0 themes work. And if your store needs customization that survives updates, let's talk.
Test yourself
3 quick questions on this lesson.
Finished this lesson?
Mark it complete to track your progress through the learning paths.