Week 23: WooCommerce Customization

WooCommerce turns a WordPress site into a store — and it's built entirely from the concepts of the last four weeks: products are a Custom Post Type, orders and cart items follow the same hooks-and-filters extension model, and a WooCommerce "extension" is just Week 20's plugin pattern applied to store-specific hooks.

Module 20 of 28 Week 23 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Understand the WooCommerce data model: products, orders & customers
  • Hook into the cart & checkout lifecycle at the right points
  • Build a small custom WooCommerce extension as a proper plugin

1. The Data Model

WooCommerce doesn't reinvent WordPress's storage — it builds directly on top of the concepts from Weeks 17–21:

  • Products — a product Custom Post Type (exactly Week 19's pattern), with price, SKU and stock stored as post meta, plus product_cat/product_tag custom taxonomies
  • Orders — since WooCommerce 8+, stored in dedicated custom tables (wc_orders and related) rather than posts — WooCommerce's own version of Week 21's "when to use a custom table" decision, made at scale
  • Customers — WordPress users (Week 13's wp_users), with WooCommerce-specific meta for billing/shipping addresses and order history
terminal — installing it
wp plugin install woocommerce --activate
wp plugin install storefront --activate # or any WooCommerce-compatible theme
reading a product programmatically
<?php

$product = wc_get_product($productId); // never query product post meta directly -- use this

echo $product->get_name();
echo $product->get_price();
echo $product->is_in_stock() ? 'In stock' : 'Sold out';

wc_get_product() returns a proper WC_Product object with typed getter methods — the same discipline Week 17 recommended for wp_posts ("never query directly, use the API") applies just as strongly here, and matters even more given how much internal complexity WooCommerce's product data actually has (variations, stock management, tax classes).

2. Product Display Hooks

WooCommerce's product page is built from a dense sequence of actions — rather than overriding the whole template, you hook into (or remove) specific pieces:

functions.php — adding a badge near the price
<?php

function my_store_low_stock_badge() {
    global $product;

    if ($product->get_stock_quantity() <= 3 && $product->is_in_stock()) {
        echo '<p class="low-stock-badge">Only ' . $product->get_stock_quantity() . ' left!</p>';
    }
}
add_action('woocommerce_single_product_summary', 'my_store_low_stock_badge', 25);
// Priority 25 -- placed right after the price (priority 10) and before the add-to-cart form (30)

This is exactly Week 20's hook priority discussion, put to real use — WooCommerce documents the default priority of each piece of the product page precisely so extensions can insert new content at an exact position without touching core template files.

3. Cart & Checkout Hooks

Business logic around pricing and validation hooks into the cart and checkout lifecycle rather than the display layer:

functions.php — a bulk discount
<?php

function my_store_bulk_discount(\WC_Cart $cart) {
    foreach ($cart->get_cart() as $item) {
        if ($item['quantity'] >= 10) {
            $discounted = $item['data']->get_price() * 0.9; // 10% off, 10+ units
            $item['data']->set_price($discounted);
        }
    }
}
add_action('woocommerce_before_calculate_totals', 'my_store_bulk_discount');

function my_store_require_phone_at_checkout(array $fields): array {
    $fields['billing']['billing_phone']['required'] = true;
    return $fields; // a filter -- must return the (possibly modified) value
}
add_filter('woocommerce_checkout_fields', 'my_store_require_phone_at_checkout');

woocommerce_before_calculate_totals is an action (mutate the cart object, return nothing); woocommerce_checkout_fields is a filter (receive the fields array, return the modified version) — the exact action/filter distinction from Week 18, now applied to store-specific data.

4. The Order Lifecycle

An order moves through a defined sequence of statuses, and hooking status transitions is the standard way to trigger side effects — inventory updates, fulfillment emails, integrations with an external system:

order status flow
pending → processing → completed
                    ↘ on-hold
                    ↘ cancelled / refunded / failed
functions.php — reacting to a status change
<?php

function my_store_notify_fulfillment_on_processing(int $orderId) {
    $order = wc_get_order($orderId);

    wp_mail(
        'fulfillment@example.com',
        "New order #{$orderId} ready to fulfill",
        "Order total: " . $order->get_total()
    );
}
add_action('woocommerce_order_status_processing', 'my_store_notify_fulfillment_on_processing');

This is structurally identical to Week 16's Laravel TaskCompleted event pattern — a status transition is the "something happened" moment, and a hooked function is the "here's what should happen as a result," decoupled from wherever the status change originally occurred.

5. A Custom Extension

A "WooCommerce extension" is just Week 20's plugin pattern, targeting WooCommerce-specific hooks instead of core WordPress ones — nothing structurally new:

wp-content/plugins/my-store-extras/my-store-extras.php
<?php
/**
 * Plugin Name: My Store Extras
 * Description: Bulk discounts and low-stock badges for the shop.
 * Version: 1.0.0
 */

if (!defined('ABSPATH')) exit;

// Good practice: confirm WooCommerce is actually active before hooking anything
add_action('plugins_loaded', function () {
    if (!class_exists('WooCommerce')) {
        return;
    }

    require_once __DIR__ . '/includes/product-display.php';
    require_once __DIR__ . '/includes/cart-rules.php';
});

Checking class_exists('WooCommerce') before hooking anything WooCommerce-specific matters: if a site administrator ever deactivates WooCommerce while this extension stays active, every hooked function referencing WC_Cart/wc_get_product() would otherwise fatal-error instead of failing gracefully.

6. Hands-on Exercise

Hands-on

Build a small WooCommerce extension

A real store, customized through hooks rather than template overrides.

Requirements:

  1. Install WooCommerce and a compatible theme, and create at least 6 products across 2 categories, with varying stock quantities.
  2. Build a my-store-extras plugin (checking class_exists('WooCommerce') before hooking anything) that adds a low-stock badge on the product page for items with 3 or fewer in stock.
  3. Add a 10% bulk discount for any cart line item with quantity 10+, via woocommerce_before_calculate_totals.
  4. Make the phone number field required at checkout via the woocommerce_checkout_fields filter.
  5. Hook woocommerce_order_status_completed to log the order ID and total to a file (reusing Week 5's file-writing pattern) — a simple stand-in for a real fulfillment integration.
Hint

Use WooCommerce's own "Booster" hooks reference (searchable as "WooCommerce hooks" in their developer docs) to find the exact hook name and its default priority for anything not covered here — the pattern is always the same once you know where to look.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does this lesson call wc_get_product() rather than reading a product's price directly from post meta?

Product data in WooCommerce is more complex than a single meta value — variations, sale pricing, tax classes and stock management all interact. wc_get_product() returns a proper WC_Product object whose getter methods (get_price(), is_in_stock()) correctly account for all of that, the same "never query the raw storage directly, use the provided API" discipline flagged for wp_posts back in Week 17.

Q2

Why does my_store_bulk_discount hook woocommerce_before_calculate_totals and not return anything, while my_store_require_phone_at_checkout hooks woocommerce_checkout_fields and must return the array?

The first is an action — it mutates the passed-in WC_Cart object directly and WooCommerce doesn't expect a return value. The second is a filter — WooCommerce passes in the current fields array and uses whatever the hooked function returns as the new value, so forgetting to return $fields would silently wipe out the checkout fields entirely. This is exactly the action/filter distinction from Week 18, just applied to WooCommerce-specific hooks.

Q3

Why check class_exists('WooCommerce') before hooking any WooCommerce-specific function in a plugin?

WooCommerce is itself a plugin, not a WordPress core feature — nothing guarantees it's active. If a site administrator deactivates WooCommerce while this extension stays active, any hooked function referencing WooCommerce-only classes like WC_Cart would trigger a fatal "class not found" error the moment it runs, taking down that part of the site rather than simply doing nothing.

Q4

What earlier concept in this course does hooking woocommerce_order_status_completed most directly resemble?

Week 16's Laravel events and listeners — an order status transition is "something happened," and a hooked function is "here's what should happen as a result," entirely decoupled from wherever the status change was actually triggered. Both patterns exist to let side effects be added or removed independently of the core logic that causes them.