
WooCommerce has hundreds of hooks. The official documentation lists them all, which sounds helpful until you’re staring at a wall of hook names trying to figure out which one fires where you actually need it. After building WooCommerce extensions for a few years, I keep coming back to the same couple dozen. This is a reference for those — organised by where in WooCommerce they fire, with examples you can paste directly into a plugin.
If you’re new to the WordPress hook system itself, the WordPress actions and filters guide covers the fundamentals before you read this.
These fire on single product pages (single-product.php template). Most are self-explanatory from their names — they fire before or after specific elements on the page.
// Add content before the product title
add_action( 'woocommerce_single_product_summary', 'my_before_product_title', 4 );
function my_before_product_title(): void {
echo 'New Arrival';
}
// Add content after the product price (priority 15 puts it after price at 10, before cart at 30)
add_action( 'woocommerce_single_product_summary', 'my_after_price_content', 15 );
function my_after_price_content(): void {
global $product;
if ( $product->is_on_sale() ) {
echo 'Sale ends Sunday.
';
}
}
// Add a tab to the product tabs section
add_filter( 'woocommerce_product_tabs', 'my_custom_product_tab' );
function my_custom_product_tab( array $tabs ): array {
$tabs['shipping_info'] = [
'title' => 'Shipping Info',
'priority' => 50,
'callback' => 'my_shipping_tab_content',
];
return $tabs;
}
function my_shipping_tab_content(): void {
echo 'Shipping Information
';
echo 'Free shipping on orders over $50.
';
}
// Modify the "Add to cart" button text
add_filter( 'woocommerce_product_single_add_to_cart_text', 'my_add_to_cart_text' );
function my_add_to_cart_text(): string {
return 'Buy Now';
}
PHPThe priority number on woocommerce_single_product_summary controls where your content appears in the stack. WooCommerce’s own elements use these priorities: title = 5, rating = 10, price = 10, excerpt = 20, add-to-cart = 30, meta = 40, sharing = 50. Set yours between those values to slot in where you need.
// Add content before the shop loop
add_action( 'woocommerce_before_shop_loop', 'my_before_shop_content', 20 );
function my_before_shop_content(): void {
echo 'Summer sale — 20% off everything.';
}
// Add content to each product card in the loop (after the title)
add_action( 'woocommerce_after_shop_loop_item_title', 'my_product_card_content', 15 );
function my_product_card_content(): void {
global $product;
echo 'SKU: ' . esc_html( $product->get_sku() ) . '';
}
// Change the number of products per row
add_filter( 'loop_shop_columns', 'my_shop_columns' );
function my_shop_columns(): int {
return 4; // default is 3 or 4 depending on theme
}
// Change products per page
add_filter( 'loop_shop_per_page', 'my_products_per_page' );
function my_products_per_page(): int {
return 24;
}
// Remove the sorting dropdown above the shop loop
remove_action( 'woocommerce_before_shop_loop', 'woocommerce_catalog_ordering', 30 );
PHP// Add a notice above the cart table
add_action( 'woocommerce_before_cart', 'my_cart_notice' );
function my_cart_notice(): void {
$threshold = 50;
$total = (float) WC()->cart->get_subtotal();
$remaining = $threshold - $total;
if ( $remaining > 0 ) {
wc_print_notice(
sprintf( 'Add $%.2f more to get free shipping!', $remaining ),
'notice'
);
}
}
// Add a custom fee to the cart
add_action( 'woocommerce_cart_calculate_fees', 'my_handling_fee' );
function my_handling_fee(): void {
if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
return;
}
WC()->cart->add_fee( 'Handling Fee', 2.50, true ); // true = taxable
}
// Validate cart item — prevent adding if a condition fails
add_filter( 'woocommerce_add_to_cart_validation', 'my_cart_validation', 10, 3 );
function my_cart_validation( bool $passed, int $product_id, int $quantity ): bool {
$product = wc_get_product( $product_id );
if ( $product && $product->get_stock_quantity() < $quantity ) {
wc_add_notice( 'Not enough stock for that quantity.', 'error' );
return false;
}
return $passed;
}
// Modify cart item data (adds custom data to the cart item)
add_filter( 'woocommerce_add_cart_item_data', 'my_cart_item_data', 10, 2 );
function my_cart_item_data( array $cart_item_data, int $product_id ): array {
$cart_item_data['custom_note'] = 'Gift wrap requested';
return $cart_item_data;
}
PHP// Add a custom field to the checkout form
add_action( 'woocommerce_after_order_notes', 'my_checkout_field' );
function my_checkout_field( WC_Checkout $checkout ): void {
woocommerce_form_field( 'custom_field', [
'type' => 'text',
'class' => [ 'form-row-wide' ],
'label' => 'Delivery instructions',
'placeholder' => 'Any special instructions for delivery?',
], $checkout->get_value( 'custom_field' ) );
}
// Validate the custom field
add_action( 'woocommerce_checkout_process', 'my_checkout_field_validation' );
function my_checkout_field_validation(): void {
// This field is optional — validate only if filled
if ( ! empty( $_POST['custom_field'] ) && strlen( $_POST['custom_field'] ) > 500 ) {
wc_add_notice( 'Delivery instructions must be under 500 characters.', 'error' );
}
}
// Save the custom field to the order
add_action( 'woocommerce_checkout_update_order_meta', 'my_save_checkout_field' );
function my_save_checkout_field( int $order_id ): void {
if ( ! empty( $_POST['custom_field'] ) ) {
update_post_meta(
$order_id,
'_custom_field',
sanitize_textarea_field( $_POST['custom_field'] )
);
}
}
// Display the saved field on the order admin screen
add_action( 'woocommerce_admin_order_data_after_billing_address', 'my_display_order_field' );
function my_display_order_field( WC_Order $order ): void {
$value = get_post_meta( $order->get_id(), '_custom_field', true );
if ( $value ) {
echo 'Delivery instructions:
' . esc_html( $value ) . '';
}
}
PHP// Fires when an order is placed (payment received or COD)
add_action( 'woocommerce_thankyou', 'my_order_placed', 10, 1 );
function my_order_placed( int $order_id ): void {
$order = wc_get_order( $order_id );
if ( ! $order ) return;
// Log or trigger an external system
error_log( 'New order: ' . $order_id . ' — ' . $order->get_total() );
}
// Fires when order status changes
add_action( 'woocommerce_order_status_changed', 'my_order_status_change', 10, 4 );
function my_order_status_change( int $order_id, string $old_status, string $new_status, WC_Order $order ): void {
if ( $new_status === 'completed' ) {
// Award loyalty points, update CRM, etc.
}
}
// Fires when payment is complete (most reliable hook for order completion)
add_action( 'woocommerce_payment_complete', 'my_payment_complete' );
function my_payment_complete( int $order_id ): void {
$order = wc_get_order( $order_id );
// Trigger fulfilment, send to warehouse, etc.
}
// Add a custom column to the orders list
add_filter( 'manage_woocommerce_page_wc-orders_columns', 'my_order_columns' );
function my_order_columns( array $columns ): array {
$columns['custom_field'] = 'Delivery Notes';
return $columns;
}
add_action( 'manage_woocommerce_page_wc-orders_custom_column', 'my_order_column_content', 10, 2 );
function my_order_column_content( string $column, WC_Order $order ): void {
if ( $column === 'custom_field' ) {
echo esc_html( $order->get_meta( '_custom_field' ) );
}
}
PHPNote the column hook names above — WooCommerce 7.1+ moved orders to a custom table (HPOS), so the column hooks use woocommerce_page_wc-orders rather than the old edit-shop_order. If you’re supporting older WooCommerce versions, you may need both.
// Add content to a specific WooCommerce email
add_action( 'woocommerce_email_after_order_table', 'my_email_content', 10, 4 );
function my_email_content( WC_Order $order, bool $sent_to_admin, bool $plain_text, WC_Email $email ): void {
// Only add to customer emails, not admin notifications
if ( $sent_to_admin ) return;
$value = $order->get_meta( '_custom_field' );
if ( $value ) {
echo 'Delivery instructions:
' . esc_html( $value ) . '';
}
}
// Modify email subject
add_filter( 'woocommerce_email_subject_new_order', 'my_email_subject', 10, 2 );
function my_email_subject( string $subject, WC_Order $order ): string {
return 'New order #' . $order->get_order_number() . ' — ' . get_bloginfo( 'name' );
}
// Add a recipient to the admin new order email
add_filter( 'woocommerce_email_recipient_new_order', 'my_email_recipient', 10, 2 );
function my_email_recipient( string $recipient, WC_Order $order ): string {
return $recipient . ',[email protected]';
}
// Disable a specific WooCommerce email entirely
add_filter( 'woocommerce_email_enabled_customer_processing_order', '__return_false' );
PHP// Modify product price programmatically
add_filter( 'woocommerce_product_get_price', 'my_dynamic_price', 10, 2 );
add_filter( 'woocommerce_product_get_regular_price', 'my_dynamic_price', 10, 2 );
function my_dynamic_price( string $price, WC_Product $product ): string {
// Apply 10% discount to a specific category
if ( has_term( 'members-only', 'product_cat', $product->get_id() ) && is_user_logged_in() ) {
return (string) ( (float) $price * 0.9 );
}
return $price;
}
// Modify the displayed price HTML
add_filter( 'woocommerce_get_price_html', 'my_price_html', 10, 2 );
function my_price_html( string $price, WC_Product $product ): string {
if ( $product->get_id() === 42 ) {
return $price . ' (inc. VAT)';
}
return $price;
}
PHPBe careful with price filters. They fire on every product render — in the loop, on single pages, in the cart, in admin. If your logic is expensive (a database query, an API call), cache the result. Doing a fresh query on every woocommerce_product_get_price call will noticeably slow down shop pages with many products. For how to query products efficiently, the WooCommerce product query guide covers the right approach.
| Hook | Type | Where it fires |
|---|---|---|
woocommerce_single_product_summary | Action | Product page summary area |
woocommerce_product_tabs | Filter | Product page tabs |
woocommerce_product_single_add_to_cart_text | Filter | Add to cart button text |
woocommerce_before_shop_loop | Action | Before shop/archive product grid |
woocommerce_after_shop_loop_item_title | Action | After each product card title in loop |
loop_shop_per_page | Filter | Products per page count |
woocommerce_before_cart | Action | Before cart table |
woocommerce_cart_calculate_fees | Action | When cart fees are calculated |
woocommerce_add_to_cart_validation | Filter | Before item added to cart |
woocommerce_after_order_notes | Action | Checkout form, after order notes |
woocommerce_checkout_process | Action | Checkout form validation |
woocommerce_checkout_update_order_meta | Action | After order created at checkout |
woocommerce_payment_complete | Action | After payment confirmed |
woocommerce_order_status_changed | Action | When order status changes |
woocommerce_email_after_order_table | Action | WooCommerce emails, after order table |
woocommerce_email_recipient_* | Filter | Email recipient for each email type |
woocommerce_product_get_price | Filter | Product price (every render) |
woocommerce_get_price_html | Filter | Displayed price HTML |
The WooCommerce source code is the most reliable reference. Install Query Monitor and enable its hooks panel — it shows every hook that fired on the current page load. For template hooks specifically, the WooCommerce GitHub repo has a woocommerce/templates/ directory where you can read exactly which hooks fire in each template file and in what order.
woocommerce_thankyou fires when the thank-you page loads — which happens every time someone visits that URL, including on page refresh. woocommerce_payment_complete fires once when payment is actually confirmed. For anything you only want to run once per order (sending to a fulfilment system, awarding points), use woocommerce_payment_complete. For page-level customisation on the thank-you screen, woocommerce_thankyou is fine.
Use remove_action() or remove_filter() with the same priority the hook was registered at. For example, to remove the related products section: remove_action( 'woocommerce_after_single_product_summary', 'woocommerce_output_related_products', 20 ). If the priority doesn’t match, nothing is removed — check the WooCommerce source to confirm the exact priority used. The WordPress actions and filters guide covers the remove pattern in detail.
A plugin is the right place — always. Hooks in functions.php are tied to the theme, so they disappear when the theme changes. Any WooCommerce customisation that your client or store depends on belongs in a plugin. For the WordPress plugin folder structure approach, keep WooCommerce-specific hooks in a dedicated file like includes/class-woocommerce-hooks.php rather than the main plugin file.
Most do, but some legacy hooks that relied on post meta directly (using get_post_meta() on order data) may not fire correctly when HPOS is active. The safe approach is to always use WooCommerce’s order methods ($order->get_meta(), $order->update_meta_data()) rather than WordPress’s post meta functions directly. WooCommerce 7.1+ is HPOS-enabled by default on new installs, so this matters if you’re building extensions meant to be distributed.

I’m Kamal, a WordPress developer focused on plugins, APIs, and scalable products.
Learn More