WooCommerce’s email system is built on the abstract WC_Email class, which handles template rendering, header/footer wrapping with the WooCommerce email CSS framework, SMTP delivery via wp_mail(), admin settings fields for subject/heading/recipient, and placeholder substitution for dynamic values like order number and customer name. Every built-in WooCommerce email — new order, processing, completed, refunded, etc. — extends WC_Email and is registered in the woocommerce_email_classes filter. Custom emails follow the same pattern: extend WC_Email, define $this->template_html and $this->template_plain pointing to PHP template files, add an add_action() inside the constructor that binds a WooCommerce hook to $this->trigger(), implement trigger() to load the order, set $this->recipient and $this->placeholders, and call $this->send(). The hook naming convention for status-transition emails is woocommerce_order_status_{from}_{to}_notification (order changed from from to to) or woocommerce_order_status_{to}_notification (order arrived at to from any status) — WooCommerce fires both when an order status changes. Template files follow WordPress’s overrideable template hierarchy: WooCommerce first looks in child-theme/woocommerce/{template}, then the plugin’s own templates/ directory declared in the email’s $this->template_base — this lets stores override the email’s HTML in their theme without modifying the plugin. wc_get_template_html() renders a template file to a string using output buffering, exposing the order object and email instance as template variables. The $this->placeholders array combined with $this->format_string() enables store owners to customise the subject and heading in WooCommerce → Settings → Emails using tokens like {order_number}, {order_date}, and any custom tokens your plugin defines. The WooCommerce order lifecycle post covered the standard status graph; this post extends that with a custom “Shipped” status that triggers a custom WC_Email subclass.
Problem: A WooCommerce store ships orders via a third-party courier and marks them “Shipped” when the courier API confirms pickup. WooCommerce’s built-in “Completed” email fires when the store physically delivers the product — but customers want an email at the earlier Shipped stage with a tracking number included in the email body.
Solution: Register a custom wc-custom-shipped order status, extend WC_Email as WC_Email_Custom_Shipped, bind it to the status-transition hook, and create an HTML template that exposes the order object so the tracking meta can be rendered inline.
// ── Extend WC_Email to create a custom order notification ─────────────────
class WC_Email_Custom_Shipped extends WC_Email {
public function __construct() {
$this->id = 'custom_order_shipped';
$this->title = __( 'Custom: Order Shipped', 'myplugin' );
$this->description = __( 'Sent to the customer when order status changes to "Shipped".', 'myplugin' );
$this->template_html = 'emails/custom-order-shipped.php';
$this->template_plain = 'emails/plain/custom-order-shipped.php';
$this->template_base = MYPLUGIN_PATH . 'templates/';
// Trigger: our custom status transition hook
$this->customer_email = true;
add_action( 'woocommerce_order_status_custom-shipped_notification', [ $this, 'trigger' ], 10, 2 );
// Call parent AFTER setting properties
parent::__construct();
$this->recipient = $this->get_option( 'recipient', '' );
}
/** @param int $order_id */
public function trigger( int $order_id, WC_Order $order = null ): void {
$this->setup_locale();
$order = $order instanceof WC_Order ? $order : wc_get_order( $order_id );
if ( ! $order instanceof WC_Order ) {
$this->restore_locale();
return;
}
$this->object = $order;
$this->recipient = $order->get_billing_email();
$this->placeholders['{order_number}'] = $order->get_order_number();
$this->placeholders['{order_date}'] = wc_format_datetime( $order->get_date_created() );
if ( $this->is_enabled() && $this->get_recipient() ) {
$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
}
$this->restore_locale();
}
public function get_subject(): string {
return $this->format_string( $this->get_option(
'subject',
/* translators: %s: order number */
sprintf( __( 'Your order #%s has been shipped!', 'myplugin' ), '{order_number}' )
) );
}
public function get_heading(): string {
return $this->format_string( $this->get_option(
'heading',
__( 'Your order is on its way', 'myplugin' )
) );
}
public function get_content_html(): string {
return wc_get_template_html(
$this->template_html,
[
'order' => $this->object,
'email_heading' => $this->get_heading(),
'additional_content' => $this->get_additional_content(),
'sent_to_admin' => false,
'plain_text' => false,
'email' => $this,
],
'',
$this->template_base
);
}
public function get_content_plain(): string {
return wc_get_template_html(
$this->template_plain,
[
'order' => $this->object,
'email_heading' => $this->get_heading(),
'additional_content' => $this->get_additional_content(),
'sent_to_admin' => false,
'plain_text' => true,
'email' => $this,
],
'',
$this->template_base
);
}
public function init_form_fields(): void {
$this->form_fields = array_merge( [
'tracking_info_note' => [
'title' => __( 'Plugin Note', 'myplugin' ),
'type' => 'title',
'desc' => __( 'This email is sent when an order moves to "Shipped" status.', 'myplugin' ),
],
], parent::get_form_fields() );
}
}
// ── Register the email class with WooCommerce ──────────────────────────────
add_filter( 'woocommerce_email_classes', function( array $emails ): array {
$emails['WC_Email_Custom_Shipped'] = new WC_Email_Custom_Shipped();
return $emails;
} );
// ── Register the custom "shipped" order status ──────────────────────────────
add_action( 'init', function(): void {
register_post_status( 'wc-custom-shipped', [
'label' => _x( 'Shipped', 'WooCommerce order status', 'myplugin' ),
'public' => false,
'show_in_admin_all_list' => true,
'show_in_admin_status_list' => true,
'label_count' => _n_noop( 'Shipped (%s)', 'Shipped (%s)', 'myplugin' ),
] );
} );
add_filter( 'wc_order_statuses', function( array $statuses ): array {
$statuses['wc-custom-shipped'] = _x( 'Shipped', 'WooCommerce order status', 'myplugin' );
return $statuses;
} );
<?php
// templates/emails/custom-order-shipped.php
// Variables: $order (WC_Order), $email_heading (string), $plain_text (bool), $email (WC_Email)
defined( 'ABSPATH' ) || exit;
// WooCommerce standard header (logo, brand colour from WooCommerce email settings)
do_action( 'woocommerce_email_header', $email_heading, $email ); ?>
<p><?php
/* translators: %s: customer first name */
printf(
esc_html__( 'Hi %s, great news!', 'myplugin' ),
esc_html( $order->get_billing_first_name() )
);
?></p>
<p><?php esc_html_e( 'Your order has been picked up by our courier and is on its way.', 'myplugin' ); ?></p>
<?php
$tracking = $order->get_meta( '_courier_tracking_number', true );
if ( $tracking ) : ?>
<p><strong><?php esc_html_e( 'Tracking Number:', 'myplugin' ); ?></strong>
<?php echo esc_html( $tracking ); ?></p>
<?php endif; ?>
<?php do_action( 'woocommerce_order_details_before_order_table', $order ); ?>
<?php
// Standard WooCommerce order table with items, totals, and billing address
do_action( 'woocommerce_email_order_details', $order, false, false, $email );
do_action( 'woocommerce_email_order_meta', $order, false, false, $email );
do_action( 'woocommerce_email_customer_details', $order, false, false, $email );
do_action( 'woocommerce_email_footer', $email );
NOTE: The parent::__construct() call in a WC_Email subclass must come after all $this-> property assignments including $this->template_html, $this->template_plain, and $this->template_base — the parent constructor reads these properties to load saved option values and default them if not set. Reversing this order means get_content_html() points to the wrong path. Also, the woocommerce_email_classes filter fires on every request — instantiating the email class inside this filter (as WooCommerce itself does) is intentional and not a performance concern because WC_Email::__construct() only sets properties and registers hooks, it does not connect to the database or perform I/O.