Subtle diagonal pinstripe pattern in light grey and cream, giving an impression of printed editorial paper

Building a custom WooCommerce product type from scratch

WooCommerce provides familiar product types such as simple, grouped, external and variable products, but those options do not fit every store. A digital consultation, equipment hire, membership entitlement, deposit, service package or pre-order may require different purchasing rules and product data. A custom product type lets you give that item its own identity without forcing unrelated behaviour into a simple product.

The safest approach is to extend WooCommerce’s existing product architecture rather than replacing it. You can register a new product type, connect it to a PHP product class, add administrator fields, and customise cart or checkout behaviour with supported hooks. Before writing code, it is useful to compare the implementation with examples in a practical WordPress Q&A resource when a WooCommerce hook behaves differently between releases.

Decide what the product type should do

Start with the commercial behaviour, not the class name. Define whether the product has a fixed price, requires shipping, manages stock, needs customer input, creates a downloadable entitlement or uses a special checkout process. A type called service might use the regular WooCommerce price, remain virtual, and store a service duration in minutes. A type called hire_item might require shipping, deposits and return dates.

This decision affects the inheritance strategy. Extending WC_Product_Simple is suitable when the custom item has one price and one purchasable unit. Extending WC_Product_Variable makes sense when customers select variations. For a rental product with dates and availability, a simple product class may still be appropriate, while booking validation and availability checks are added through cart and checkout hooks.

Keep the product type slug short and stable. In this example, the slug is service. The slug becomes part of the product type taxonomy, PHP conditionals, admin CSS classes and sometimes URLs or integrations. Changing it later can leave existing products assigned to an obsolete taxonomy term.

A virtual service is also a useful example for an Australian store. A Melbourne consultancy might sell a fixed-fee website audit, while a Sydney business could sell a remote training session. The store can display prices in Australian dollars and apply GST through WooCommerce tax settings without adding tax calculations directly to the product class.

Register the type and product class

Place the implementation in a small custom plugin rather than a theme’s functions.php file. A theme can be replaced, while a product type is part of the store’s catalogue and should remain available when the presentation layer changes. The plugin should load after WooCommerce and fail safely if WooCommerce is inactive.

<?php
/**
 * Plugin Name: QA Service Product Type
 */

defined( 'ABSPATH' ) || exit;

add_filter( 'product_type_selector', function ( $types ) {
    $types['service'] = __( 'Service', 'qa-service' );
    return $types;
} );

add_action( 'init', function () {
    if ( ! taxonomy_exists( 'product_type' ) ) {
        return;
    }

    if ( ! term_exists( 'service', 'product_type' ) ) {
        wp_insert_term(
            __( 'Service', 'qa-service' ),
            'product_type',
            array(
                'slug' => 'service',
            )
        );
    }
}, 20 );

add_filter(
    'woocommerce_product_class',
    function ( $classname, $product_type, $product_id ) {
        if ( 'service' === $product_type ) {
            return 'QA_Product_Service';
        }

        return $classname;
    },
    10,
    3
);

if ( class_exists( 'WC_Product_Simple' ) ) {
    class QA_Product_Service extends WC_Product_Simple {

        public function get_type() {
            return 'service';
        }

        public function is_virtual() {
            return true;
        }

        public function needs_shipping() {
            return false;
        }

        public function is_purchasable() {
            return parent::is_purchasable()
                && '' !== $this->get_meta( '_service_duration', true );
        }
    }
}

The selector adds the type to the Product data dropdown. WooCommerce already registers the product_type taxonomy, so the plugin only creates the service term when necessary. The woocommerce_product_class filter tells the product factory which PHP class to instantiate when wc_get_product() loads a service item.

The class inherits pricing, sale dates, stock handling and much of the standard simple-product functionality. Its get_type() method must return the same slug used by the selector and taxonomy term. The overridden shipping methods make the product virtual, while is_purchasable() prevents checkout until an administrator has entered a service duration.

When a new WooCommerce release changes an internal method, inherited behaviour is generally easier to maintain than a completely independent WC_Product implementation. Avoid copying the entire simple-product class into your plugin. That creates a maintenance burden and can cause incompatibilities with HPOS, payment gateways and product data stores.

Add fields to the product editor

A custom product type usually needs metadata that does not belong on a standard product. For the service example, administrators need a duration and perhaps an internal delivery note. Use WooCommerce’s field helpers for the admin interface, then save values through the product object rather than calling update_post_meta() directly.

add_action( 'woocommerce_product_options_general_product_data', function () {
    echo '<div class="options_group show_if_service">';

    woocommerce_wp_text_input(
        array(
            'id'                => '_service_duration',
            'label'             => __( 'Duration (minutes)', 'qa-service' ),
            'type'              => 'number',
            'desc_tip'          => true,
            'description'       => __( 'Used for service fulfilment.', 'qa-service' ),
            'custom_attributes' => array(
                'min'  => '1',
                'step' => '1',
            ),
        )
    );

    woocommerce_wp_textarea_input(
        array(
            'id'          => '_service_instructions',
            'label'       => __( 'Fulfilment instructions', 'qa-service' ),
            'description' => __( 'Visible to staff after purchase.', 'qa-service' ),
        )
    );

    echo '</div>';
} );

add_action( 'woocommerce_admin_process_product_object', function ( $product ) {
    if ( 'service' !== $product->get_type() ) {
        return;
    }

    if ( isset( $_POST['_service_duration'] ) ) {
        $duration = absint( wp_unslash( $_POST['_service_duration'] ) );

        if ( $duration > 0 ) {
            $product->update_meta_data( '_service_duration', $duration );
        } else {
            $product->delete_meta_data( '_service_duration' );
        }
    }

    if ( isset( $_POST['_service_instructions'] ) ) {
        $instructions = sanitize_textarea_field(
            wp_unslash( $_POST['_service_instructions'] )
        );

        $product->update_meta_data(
            '_service_instructions',
            $instructions
        );
    }
} );

The show_if_service class allows WooCommerce’s product editor JavaScript to display the fields when the custom type is selected. The fields may be present in the markup for other product types, but the interface hides them. This also means the controls remain available when an administrator changes the product type without refreshing the page.

Input must be sanitised when it is received, and output must be escaped when it is displayed. A numeric duration is converted with absint(), while textarea content uses sanitize_textarea_field(). For more sensitive workflows, also check the current user’s capability and verify the relevant admin nonce before processing custom data.

Use product CRUD methods such as get_meta(), update_meta_data() and delete_meta_data(). This keeps the code compatible with WooCommerce’s storage abstraction and avoids assuming that every product is stored as a traditional WordPress post in the same way.

Customise cart, checkout and order data

A product class controls product-level behaviour, but customer-entered information belongs in the cart and order lifecycle. Suppose a service requires an appointment reference or a preferred delivery date. Validate that value when the item is added, attach it to the cart item, display it in the cart, and copy it to the order line item.

add_filter(
    'woocommerce_add_to_cart_validation',
    function ( $passed, $product_id, $quantity ) {
        $product = wc_get_product( $product_id );

        if ( $product && 'service' === $product->get_type() ) {
            if ( empty( $_POST['service_reference'] ) ) {
                wc_add_notice(
                    __( 'Please enter a service reference.', 'qa-service' ),
                    'error'
                );

                return false;
            }
        }

        return $passed;
    },
    10,
    3
);

add_filter(
    'woocommerce_add_cart_item_data',
    function ( $cart_item_data, $product_id ) {
        $product = wc_get_product( $product_id );

        if (
            $product &&
            'service' === $product->get_type() &&
            isset( $_POST['service_reference'] )
        ) {
            $cart_item_data['service_reference'] = sanitize_text_field(
                wp_unslash( $_POST['service_reference'] )
            );
        }

        return $cart_item_data;
    },
    10,
    2
);

add_action(
    'woocommerce_checkout_create_order_line_item',
    function ( $item, $cart_item_key, $values ) {
        if ( ! empty( $values['service_reference'] ) ) {
            $item->add_meta_data(
                __( 'Service reference', 'qa-service' ),
                $values['service_reference'],
                true
            );
        }
    },
    10,
    3
);

The example uses $_POST only after checking that the expected value exists and then sanitises it. In a production implementation, use a nonce appropriate to the form and validate the value against the actual product requirements. Never trust a hidden field to identify the product type or set a price; load the product from its ID and apply server-side rules.

For a downloadable or booked service, you may need additional hooks. woocommerce_before_calculate_totals can adjust a cart price, although price changes should be carefully validated and made visible to the customer. woocommerce_payment_complete or order-status hooks can trigger fulfilment after payment. Avoid marking an order complete before the payment gateway has confirmed the transaction.

Australian businesses should also consider how customer data appears in order emails and exports. A service reference may contain a client’s business name or contact detail, so keep it necessary, protect it with normal WordPress permissions and avoid exposing internal notes on public product pages. If the product is sold during an EOFY campaign, the promotional price should still be represented through WooCommerce’s normal sale or coupon mechanisms where possible.

Test compatibility and production behaviour

Create a disposable staging product and test the complete lifecycle: selecting the type, saving it, reloading it, adding it to the cart, completing checkout, viewing the order in the dashboard and loading it through wc_get_product(). Confirm that the returned object is QA_Product_Service and that its type is service. Test both a new product and a product converted from another type.

Check behaviour for empty prices, zero quantity, missing metadata, disabled products and products moved to the trash. A custom is_purchasable() method should not accidentally allow a product to bypass WooCommerce’s stock or catalogue visibility checks. Payment gateways, analytics plugins and feeds may also inspect get_type(), so give the type a predictable value.

Use a staging copy with HPOS enabled if the live store uses it. Test order creation, refunds, admin edits and email rendering under the same configuration. If a plugin assumes every item is a simple or variable product, add compatibility filters or document the custom type for the integration. Keep the class and hooks namespaced or uniquely prefixed to avoid collisions with another extension.

Shipping behaviour deserves a specific check. A virtual service should not create an Australia Post parcel or add a delivery charge, while a physical hire item must pass the correct shipping requirements to the cart. For stores serving Perth, Brisbane and regional areas, test shipping zones and postcode rules rather than assuming that a custom product behaves like a simple product.

Finally, verify currency, tax and consumer-facing wording in the real store configuration. WooCommerce should calculate Australian GST according to the configured tax settings, while the custom type supplies product behaviour and metadata. Keep business rules out of templates where possible, document every custom hook, and make the plugin version-controlled so future WooCommerce updates can be tested before they reach production.