WooCommerce custom product types development guide showing how to extend WC_Product class

WooCommerce Custom Product Types: When and How to Build Your Own

WooCommerce ships with four product types: simple, variable, grouped, and external/affiliate. These cover most ecommerce scenarios. But when your store sells something that doesn’t fit, rental periods, configurable services, composite products with dependencies, you need a custom product type.

Building a custom product type means extending WooCommerce’s product class system, registering your type with the admin interface, handling how it behaves in the cart and checkout, and exposing it through the REST API. It’s the deepest level of WooCommerce customization, and getting it right gives you complete control over how your products work.

This guide walks through the entire process with working code examples.

When You Actually Need a Custom Product Type

Before writing code, make sure you genuinely need a custom product type. WooCommerce’s existing types are flexible, simple products with custom fields and conditional logic handle many use cases.

You need a custom product type when:

  • The pricing model is fundamentally different, Per-hour billing, tiered pricing based on usage, or prices that depend on configuration choices that can’t be handled by variations.
  • Cart behavior must change, The product needs custom data in the cart (rental dates, service configurations) that persists through checkout and appears on orders.
  • The admin editing experience needs restructuring, The standard product data tabs don’t make sense for your product. A rental product needs availability calendars, not inventory counts.
  • You need different checkout validation, The product requires validation rules that don’t apply to standard products (date range checks, capacity limits, prerequisite products).

If you just need extra fields on a simple product, use woocommerce_product_options_general_product_data and woocommerce_process_product_meta hooks instead. Much simpler, less maintenance.

Architecture: How WooCommerce Product Types Work

Every WooCommerce product is an instance of a class that extends WC_Product. The class hierarchy:

WC_Product (abstract base)
├── WC_Product_Simple
├── WC_Product_Variable
│   └── WC_Product_Variation
├── WC_Product_Grouped
├── WC_Product_External
└── Your_Custom_Product (extends WC_Product)

WooCommerce determines which class to instantiate using the product_type taxonomy term. When you call wc_get_product( $id ), WooCommerce looks up the product’s type term and maps it to a class via the woocommerce_product_class filter.

This means creating a custom product type requires three things:

  1. A product class that extends WC_Product
  2. Registration with WooCommerce’s product type selector
  3. A class-to-type mapping so WooCommerce instantiates your class

Step 1: Create the Product Class

Start with a class that extends WC_Product. This example creates a “Rental” product type:

class WC_Product_Rental extends WC_Product {

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

    public function __construct( $product = 0 ) {
        parent::__construct( $product );
    }

    // Custom properties with getters and setters
    public function get_rental_period( $context = 'view' ) {
        return $this->get_meta( '_rental_period', true, $context );
    }

    public function set_rental_period( $value ) {
        $this->update_meta_data( '_rental_period', sanitize_text_field( $value ) );
    }

    public function get_daily_rate( $context = 'view' ) {
        return $this->get_meta( '_daily_rate', true, $context );
    }

    public function set_daily_rate( $value ) {
        $this->update_meta_data( '_daily_rate', wc_format_decimal( $value ) );
    }

    public function get_deposit_amount( $context = 'view' ) {
        return $this->get_meta( '_deposit_amount', true, $context );
    }

    public function set_deposit_amount( $value ) {
        $this->update_meta_data( '_deposit_amount', wc_format_decimal( $value ) );
    }

    // Override: rental products are always purchasable if they have a daily rate
    public function is_purchasable() {
        return $this->exists() && $this->get_daily_rate() > 0;
    }

    // Override: rental products are virtual by default
    public function is_virtual() {
        return false; // Set to true for service-type rentals
    }

    // Override: customize the price display
    public function get_price_html( $deprecated = '' ) {
        $daily_rate = $this->get_daily_rate();
        if ( $daily_rate > 0 ) {
            return wc_price( $daily_rate ) . ' / day';
        }
        return '';
    }
}

Key Method Overrides

Method Purpose When to Override
get_type() Returns the product type slug Always, this identifies your type
is_purchasable() Can this product be added to cart? When purchase conditions differ from standard
get_price_html() Frontend price display When pricing format differs (per day, per hour, etc.)
is_virtual() Does this product need shipping? When your type is always virtual or always physical
is_sold_individually() Limit to one per cart? For products that can’t be duplicated (service bookings)
add_to_cart_url() URL for the add-to-cart button When you need a custom flow before adding to cart
add_to_cart_text() Button text on archives When “Add to cart” doesn’t fit (use “Book Now”, “Rent”)

Step 2: Register the Product Type

Tell WooCommerce about your new type so it appears in the product data selector and maps to the correct class:

// Register the product type in the selector dropdown
add_filter( 'product_type_selector', function( $types ) {
    $types['rental'] = 'Rental Product';
    return $types;
} );

// Map the type to your class
add_filter( 'woocommerce_product_class', function( $classname, $product_type ) {
    if ( 'rental' === $product_type ) {
        return 'WC_Product_Rental';
    }
    return $classname;
}, 10, 2 );

// Ensure the class file is loaded
add_action( 'init', function() {
    require_once plugin_dir_path( __FILE__ ) . 'includes/class-wc-product-rental.php';
} );

Step 3: Build the Admin Interface

WooCommerce product editing uses tabs in the product data metabox. You need to control which tabs show for your type and add custom fields.

Show/Hide Standard Tabs

// Control which product data tabs appear for rental products
add_filter( 'woocommerce_product_data_tabs', function( $tabs ) {
    // Show these tabs for rental type
    $tabs['general']['class'][] = 'show_if_rental';
    $tabs['inventory']['class'][] = 'show_if_rental';

    // Hide tabs that don't apply
    // Shipping tab will be hidden unless we add 'show_if_rental'

    return $tabs;
} );

Add Custom Fields to the General Tab

// Add rental-specific fields to the general product data panel
add_action( 'woocommerce_product_options_general_product_data', function() {
    global $post;

    echo '<div class="options_group show_if_rental">';

    woocommerce_wp_text_input( array(
        'id'          => '_daily_rate',
        'label'       => 'Daily Rate (' . get_woocommerce_currency_symbol() . ')',
        'desc_tip'    => true,
        'description' => 'The per-day rental price.',
        'type'        => 'text',
        'data_type'   => 'price',
    ) );

    woocommerce_wp_text_input( array(
        'id'          => '_deposit_amount',
        'label'       => 'Security Deposit (' . get_woocommerce_currency_symbol() . ')',
        'desc_tip'    => true,
        'description' => 'Refundable deposit required at checkout.',
        'type'        => 'text',
        'data_type'   => 'price',
    ) );

    woocommerce_wp_select( array(
        'id'      => '_rental_period',
        'label'   => 'Minimum Rental Period',
        'options' => array(
            '1'  => '1 day',
            '3'  => '3 days',
            '7'  => '1 week',
            '14' => '2 weeks',
            '30' => '1 month',
        ),
    ) );

    echo '</div>';
} );

Save the Custom Fields

add_action( 'woocommerce_process_product_meta_rental', function( $post_id ) {
    $product = wc_get_product( $post_id );

    if ( isset( $_POST['_daily_rate'] ) ) {
        $product->set_daily_rate( sanitize_text_field( wp_unslash( $_POST['_daily_rate'] ) ) );
    }

    if ( isset( $_POST['_deposit_amount'] ) ) {
        $product->set_deposit_amount( sanitize_text_field( wp_unslash( $_POST['_deposit_amount'] ) ) );
    }

    if ( isset( $_POST['_rental_period'] ) ) {
        $product->set_rental_period( sanitize_text_field( wp_unslash( $_POST['_rental_period'] ) ) );
    }

    $product->save();
} );

JavaScript for Admin Tab Visibility

// Enqueue admin JS to toggle tab visibility
add_action( 'admin_footer', function() {
    global $post;
    if ( ! $post || 'product' !== $post->post_type ) {
        return;
    }
    ?>
    <script>
    jQuery( function( $ ) {
        $( 'body' ).on( 'woocommerce-product-type-change', function( event, type ) {
            if ( 'rental' === type ) {
                $( '.show_if_rental' ).show();
                $( '.hide_if_rental' ).hide();
                // Hide the regular price field since we use daily_rate
                $( '._regular_price_field' ).closest( '.options_group' ).hide();
            }
        } );

        // Trigger on page load if already rental type
        $( '#product-type' ).trigger( 'change' );
    } );
    </script>
    

Step 4: Handle Cart and Checkout

Custom product types often need extra data in the cart, rental dates, configuration choices, calculated prices. Here's how to add and display that data:

Add Custom Data to Cart

// Add rental dates to cart item data
add_filter( 'woocommerce_add_cart_item_data', function( $cart_item_data, $product_id ) {
    $product = wc_get_product( $product_id );

    if ( 'rental' !== $product->get_type() ) {
        return $cart_item_data;
    }

    if ( isset( $_POST['rental_start'] ) && isset( $_POST['rental_end'] ) ) {
        $start = sanitize_text_field( wp_unslash( $_POST['rental_start'] ) );
        $end   = sanitize_text_field( wp_unslash( $_POST['rental_end'] ) );

        $cart_item_data['rental_start'] = $start;
        $cart_item_data['rental_end']   = $end;

        // Calculate total based on days
        $days = ( strtotime( $end ) - strtotime( $start ) ) / DAY_IN_SECONDS;
        $days = max( 1, intval( $days ) );
        $cart_item_data['rental_days']  = $days;
        $cart_item_data['rental_total'] = $days * floatval( $product->get_daily_rate() );

        // Unique key so same product with different dates creates separate cart items
        $cart_item_data['unique_key'] = md5( $product_id . $start . $end );
    }

    return $cart_item_data;
}, 10, 2 );

// Set the cart item price based on rental calculation
add_action( 'woocommerce_before_calculate_totals', function( $cart ) {
    if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
        return;
    }

    foreach ( $cart->get_cart() as $cart_item ) {
        if ( isset( $cart_item['rental_total'] ) ) {
            $cart_item['data']->set_price( $cart_item['rental_total'] );
        }
    }
} );

Display Custom Data in Cart and Orders

// Show rental details in cart
add_filter( 'woocommerce_get_item_data', function( $item_data, $cart_item ) {
    if ( isset( $cart_item['rental_start'] ) ) {
        $item_data[] = array(
            'key'   => 'Rental Period',
            'value' => date_i18n( 'M j, Y', strtotime( $cart_item['rental_start'] ) )
                     . ' - '
                     . date_i18n( 'M j, Y', strtotime( $cart_item['rental_end'] ) ),
        );
        $item_data[] = array(
            'key'   => 'Duration',
            'value' => $cart_item['rental_days'] . ' days',
        );
    }
    return $item_data;
}, 10, 2 );

// Save rental data to order items
add_action( 'woocommerce_checkout_create_order_line_item', function( $item, $cart_item_key, $values ) {
    if ( isset( $values['rental_start'] ) ) {
        $item->add_meta_data( '_rental_start', $values['rental_start'] );
        $item->add_meta_data( '_rental_end', $values['rental_end'] );
        $item->add_meta_data( '_rental_days', $values['rental_days'] );
    }
}, 10, 3 );

Step 5: Add to Cart Button and Single Product Template

Override the add-to-cart template to include your custom fields (date pickers, configuration options):

// Use a custom add-to-cart template for rental products
add_action( 'woocommerce_rental_add_to_cart', function() {
    wc_get_template(
        'single-product/add-to-cart/rental.php',
        array(),
        '',
        plugin_dir_path( __FILE__ ) . 'templates/'
    );
} );

// Override button text on shop archives
add_filter( 'woocommerce_product_add_to_cart_text', function( $text, $product ) {
    if ( 'rental' === $product->get_type() ) {
        return 'Select Dates';
    }
    return $text;
}, 10, 2 );

Create the template at templates/single-product/add-to-cart/rental.php:

<?php
defined( 'ABSPATH' ) || exit;
global $product;

if ( ! $product->is_purchasable() ) {
    return;
}
?>

<form class="cart rental-cart" method="post" enctype="multipart/form-data">
    <div class="rental-dates">
        <p class="form-row">
            <label for="rental_start">Start Date</label>
            <input type="date" name="rental_start" id="rental_start"
                   min="<?php echo esc_attr( date( 'Y-m-d' ) ); ?>" required>
        </p>
        <p class="form-row">
            <label for="rental_end">End Date</label>
            <input type="date" name="rental_end" id="rental_end" required>
        </p>
    </div>

    <div class="rental-summary">
        <p>Daily rate: <?php echo wc_price( $product->get_daily_rate() ); ?></p>
        <?php if ( $product->get_deposit_amount() > 0 ) : ?>
            <p>Security deposit: <?php echo wc_price( $product->get_deposit_amount() ); ?></p>
        <?php endif; ?>
    </div>

    <button type="submit" name="add-to-cart"
            value="<?php echo esc_attr( $product->get_id() ); ?>"
            class="single_add_to_cart_button button alt">
        Book Rental
    </button>
</form>

Step 6: Validation

Validate custom data before allowing the product to be added to cart:

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

    if ( 'rental' !== $product->get_type() ) {
        return $passed;
    }

    $start = isset( $_POST['rental_start'] ) ? sanitize_text_field( wp_unslash( $_POST['rental_start'] ) ) : '';
    $end   = isset( $_POST['rental_end'] ) ? sanitize_text_field( wp_unslash( $_POST['rental_end'] ) ) : '';

    if ( empty( $start ) || empty( $end ) ) {
        wc_add_notice( 'Please select both start and end dates.', 'error' );
        return false;
    }

    if ( strtotime( $end ) <= strtotime( $start ) ) {
        wc_add_notice( 'End date must be after start date.', 'error' );
        return false;
    }

    if ( strtotime( $start ) < strtotime( 'today' ) ) {
        wc_add_notice( 'Start date cannot be in the past.', 'error' );
        return false;
    }

    $min_period = intval( $product->get_rental_period() );
    $days       = ( strtotime( $end ) - strtotime( $start ) ) / DAY_IN_SECONDS;
    if ( $days < $min_period ) {
        wc_add_notice(
            sprintf( 'Minimum rental period is %d days.', $min_period ),
            'error'
        );
        return false;
    }

    return $passed;
}, 10, 2 );

Step 7: REST API Support

If your store uses the WooCommerce REST API for headless commerce or mobile apps, expose your custom product data:

// Add custom fields to REST API response
add_filter( 'woocommerce_rest_prepare_product_object', function( $response, $product ) {
    if ( 'rental' === $product->get_type() ) {
        $response->data['daily_rate']      = $product->get_daily_rate();
        $response->data['deposit_amount']  = $product->get_deposit_amount();
        $response->data['rental_period']   = $product->get_rental_period();
    }
    return $response;
}, 10, 2 );

// Allow setting custom fields via REST API
add_action( 'woocommerce_rest_insert_product_object', function( $product, $request ) {
    if ( 'rental' === $product->get_type() ) {
        if ( isset( $request['daily_rate'] ) ) {
            $product->set_daily_rate( $request['daily_rate'] );
        }
        if ( isset( $request['deposit_amount'] ) ) {
            $product->set_deposit_amount( $request['deposit_amount'] );
        }
        if ( isset( $request['rental_period'] ) ) {
            $product->set_rental_period( $request['rental_period'] );
        }
        $product->save();
    }
}, 10, 2 );

HPOS Compatibility

WooCommerce's High-Performance Order Storage (HPOS) changes how order data is stored. If your custom product type adds order meta, make sure you declare HPOS compatibility:

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
        );
    }
} );

Use $order->get_meta() and $order->update_meta_data() instead of direct get_post_meta() calls. This ensures your code works with both the legacy posts table and the new orders table.

Testing Your Custom Product Type

Before deploying, verify these scenarios:

Test Case What to Check
Create product in admin Type appears in selector, custom fields display, data saves correctly
Frontend display Price format correct, add-to-cart button text, custom template loads
Add to cart Custom data passes to cart, validation works, price calculates correctly
Cart display Custom metadata visible, price shows calculated total
Checkout Order completes, custom data saves to order, email includes details
REST API Custom fields appear in GET response, POST/PUT updates fields
HPOS Works with both legacy and HPOS storage
WooCommerce updates Test with latest WC version, check for deprecated methods

Real-World Examples

Custom product types power stores that couldn't exist with standard WooCommerce:

  • Equipment rental stores, Daily/weekly rates, availability calendars, security deposits, damage waivers. The rental type tracks who has what and when it's due back.
  • Custom printing services, Upload-based products where price depends on size, material, quantity, and finish. Each combination calculates a unique price at cart time.
  • Subscription boxes, While WooCommerce Subscriptions handles recurring billing, a custom type can manage box contents, customization preferences, and swap deadlines.
  • Event tickets, Seat selection, tiered pricing (early bird, VIP, general), date-specific inventory, and automatic expiration after the event.
  • Configurable services, Consulting or coaching packages where customers choose hours, add-ons, and scheduling during the purchase flow.

Frequently Asked Questions

Can I extend WC_Product_Simple instead of WC_Product?

Yes. If your custom type is essentially a simple product with extra features, extending WC_Product_Simple gives you all simple product behavior for free. You only override what's different. This is simpler than starting from WC_Product and reimplementing everything.

How do I make my custom product type work with WooCommerce Blocks?

WooCommerce Blocks (used in the block-based cart and checkout) need to recognize your product type. Register your type's data in the Store API using the woocommerce_store_api_product_schema and woocommerce_store_api_product_response filters. Without this, your custom data won't appear in block-based cart views.

Do custom product types work with product bundles and composite products?

It depends on how the bundle plugin discovers product types. Most bundle plugins (WooCommerce Product Bundles, Composite Products) work with any type that extends WC_Product. Test the specific combination before deploying.

How do I handle inventory for custom product types?

If your type uses standard inventory (stock quantity), WooCommerce's built-in inventory system works automatically, just include the inventory tab in your admin UI. For date-based inventory (rental availability per day), you need a custom availability table and checking logic in is_in_stock() and your validation filter.

What happens when WooCommerce updates?

WooCommerce's product class API is stable but evolves. Subscribe to the WooCommerce developer blog for deprecation notices. Key risk areas: changes to the checkout flow (especially with block-based checkout), HPOS migration requirements, and REST API version updates. Always test your custom type against WooCommerce beta releases before major updates.

Start Building

The complete code examples in this guide give you a working rental product type. Adapt the pattern for your specific use case, replace rental dates with your custom fields, adjust the pricing calculation, and modify the cart/checkout behavior.

Keep your custom product type in a dedicated plugin, not in your theme's functions.php. Product types are data-layer logic that should persist regardless of theme changes. Structure it as a proper plugin with security best practices, input sanitization, nonce verification, and capability checks on all admin operations.

Facebook
Twitter
LinkedIn
Pinterest
WhatsApp

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *