Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 04 / 06 30 MIN

Products and pricing

Product Types and the Product Object

A product type is a class plus a taxonomy term. Getting only one of the two right is how an extension ends up with products nobody can add to the cart.

The product type selector in the admin is a dropdown, so the first version of a custom product type tends to be a single line: register the type, save a product as that type, and wait for the add-to-cart button. What appears is a product page with a "Read more" button that does nothing, because "Read more" is what the base WC_Product class returns and nobody overrode it.

Two separate registrations have to agree for a product type to work. A class has to answer for the type, and a term has to exist in a taxonomy so a product can be saved as that type at all. Get the first without the second and no product is ever that type. Get the second without the first and every product of that type is a WC_Product_Simple wearing a different label, which is worse, because the admin screen and the storefront will disagree about what the product is.

One abstract class holds the product, and five classes speak for it

WC_Product is an abstract class. You never instantiate it, and new WC_Product( $id ) is not a thing you ship. It holds every property a product can have and every question the rest of WooCommerce asks about one, from get_price() to needs_shipping(). Behind it there is WC_Abstract_Legacy_Product, which exists so that a decade of $product->id and $product->post reads keep resolving, and behind that is WC_Data, which is the same base class every CRUD object in WooCommerce shares.

The concrete classes inherit from WC_Product and put a name to the type. The one that catches people out is the variation, which extends WC_Product_Simple rather than WC_Product directly.

ClassExtendsType string
WC_ProductWC_Abstract_Legacy_Productabstract, no instance
WC_Product_SimpleWC_Productsimple
WC_Product_VariableWC_Productvariable
WC_Product_VariationWC_Product_Simplevariation
WC_Product_GroupedWC_Productgrouped
WC_Product_ExternalWC_Productexternal

The type strings are constants on Automattic\WooCommerce\Enums\ProductType in 11.x, and the factory compares against those. A custom type only has to be a string that does not collide with them.

The factory chooses a class from one string

wc_get_product( $id ) delegates to WC()->product_factory->get_product(). The factory does four things, and the order matters:

  1. Turn the argument into an integer id. An object with an ID property counts, and so does the global $post when the queried post type is product.
  2. Ask the product data store for the type string.
  3. Run that string through the woocommerce_product_class filter to get a class name.
  4. Construct it. If the class does not exist, fall back to WC_Product_Simple.

The fallback in step four is the quiet part. A filter that returns a class name you have not loaded yet does not throw, does not log, and does not warn. You get a simple product, and every symptom downstream looks like a bug in your type's logic rather than a missing require.

<?php
add_filter( 'woocommerce_product_class', function ( $classname, $product_type ) {
    if ( 'gift_card' === $product_type ) {
        return 'Sifero_Product_Gift_Card';
    }
    return $classname;
}, 10, 2 );

get_type is the authority, and the taxonomy term is what gets saved

The type string is not stored as product meta. A product of type grouped has a term in the product_type taxonomy, and the data store reads it back with get_the_terms(). When a product has no term at all, the store reports simple, which is why a store that never touched the selector still works. Variations skip the taxonomy entirely: the store keys off the product_variation post type.

$product->get_type() reads the other side of the pair. It returns the string the class declares, and it is the value everything else in WooCommerce trusts. is_type( 'gift_card' ) compares against it. So does woocommerce_template_single_add_to_cart(), which does do_action( 'woocommerce_' . $product->get_type() . '_add_to_cart' ) and then stops. Nothing in that path consults the taxonomy.

That split is what makes the two failures above look so different. A product whose taxonomy term says gift_card gets a WC_Product_Simple object, so get_type() returns simple, is_type( 'gift_card' ) is false, and the single product page fires woocommerce_simple_add_to_cart. The admin says one thing and the object says another, and the object wins everywhere it counts.

There is no product_type filter

You will find add_filter( 'product_type', ... ) quoted in older tutorials. It is not a filter WooCommerce registers on any code path in 11.2, so the callback never runs. The hooks that do exist are product_type_selector for the admin dropdown, woocommerce_product_class for the class mapping, and woocommerce_product_type_query if you need to override the type lookup itself.

Behaviours a custom type inherits, and the two it must answer for

A custom class that extends WC_Product and overrides only get_type() inherits a great deal. Price, stock quantity, tax class, attributes, categories, images, downloads and the entire CRUD surface come from the base class and work without further code. So does persistence: WC_Data_Store::load( 'product-' . $type ) falls back to the product store when there is no entry for your type, so save(), get_meta() and update_meta_data() all write to the ordinary product storage.

What it does not inherit is a decision about whether the product can be bought. is_purchasable() on the base class is $this->exists() && $this->is_viewable() && '' !== $this->get_price(). A product with no price is not purchasable, and a gift card with a price is, which is probably not what you want if the card is only sold through a specific flow.

<?php
class Sifero_Product_Gift_Card extends WC_Product {

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

    public function is_purchasable() {
        $purchasable = parent::is_purchasable() && '' !== $this->get_meta( '_sifero_redeemable_from' );
        return apply_filters( 'woocommerce_is_purchasable', $purchasable, $this );
    }
}

Passing the combined result back through the filter is not decoration. The base method is the only place woocommerce_is_purchasable runs, so a subclass that returns a bare boolean silently disables the filter for every other plugin on the store, including the one the merchant installed to schedule sales.

is_purchasable and is_in_stock are what change the button

The base class returns Read more from add_to_cart_text() and never looks at stock or price. WC_Product_Simple is the class that turns those two flags into words:

<?php
// WC_Product_Simple::add_to_cart_text(), WooCommerce 11.2.1.
public function add_to_cart_text() {
    $text = $this->is_purchasable() && $this->is_in_stock()
        ? __( 'Add to cart', 'woocommerce' )
        : __( 'Read more', 'woocommerce' );
    return apply_filters( 'woocommerce_product_add_to_cart_text', $text, $this );
}

So the button is a readout of those two methods, and an override on either one moves it. is_in_stock() is one comparison on the base class: 'outofstock' !== $this->get_stock_status(), which means onbackorder counts as in stock. If your type ships as a preorder and you want the button to say so, that decision belongs in add_to_cart_text(), not in is_in_stock(), because is_in_stock() also decides whether the product appears in loops when the store hides out-of-stock items.

A custom type also needs somewhere for the button to live. The single product page runs do_action( 'woocommerce_gift_card_add_to_cart' ) at priority 30, and nothing is attached to that action, so the page renders a price, a description, and no form. One line fixes it:

<?php
add_action( 'woocommerce_gift_card_add_to_cart', function () {
    wc_get_template( 'single-product/add-to-cart/simple.php' );
} );

Reusing the simple template means the markup, the quantity input and the <form> all match the rest of the store. The point of a custom type is usually not a different form, it is a different set of rules about when the standard form should be allowed to submit.

Registering a type takes three hooks and no new storage

Put together, a working custom type is small. The class goes in a file the main plugin file loads, the class filter maps the type to it, the selector filter puts the type in the admin dropdown, and the template action renders the form. Registration order is the one thing to watch: the class has to exist before the filter that names it runs, and the woocommerce_product_class filter fires on every product load, so loading a file from inside that callback costs a file read per product on a listing page.

Lab 30 minutes

Add a gift_card product type to a WooCommerce 11.2 store. Your extension must do all of the following, and you should be able to show each one in the admin or on the storefront:

  • A class that returns gift_card from get_type() and extends the right parent.
  • The type selectable in the product data panel, with a term saved when you pick it.
  • A product of that type that is purchasable only when a custom meta key is present, and that still honours woocommerce_is_purchasable for other plugins.
  • An add-to-cart form on the single product page without copying the markup.

Then answer two questions in one sentence each. What does wc_get_product( $id )->get_type() return for a product whose taxonomy term you set to gift_card but whose class you never registered? And which single line would you delete to make that failure visible instead of silent?

Show the solution and the reasoning

Two filters and one action, plus the class. The class extends WC_Product rather than WC_Product_Simple, which matters for add_to_cart_text().

<?php
/**
 * Plugin Name: Sifero Gift Cards
 * Requires Plugins: woocommerce
 */

class Sifero_Product_Gift_Card extends WC_Product {

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

    public function is_purchasable() {
        // A gift card is only buyable once an activation date is set on it.
        $purchasable = parent::is_purchasable()
            && '' !== (string) $this->get_meta( '_sifero_redeemable_from' );

        return apply_filters( 'woocommerce_is_purchasable', $purchasable, $this );
    }

    public function add_to_cart_text() {
        // Mirror the simple class, because this class does not inherit from it.
        $text = $this->is_purchasable() && $this->is_in_stock()
            ? __( 'Add to cart', 'woocommerce' )
            : __( 'Read more', 'woocommerce' );

        return apply_filters( 'woocommerce_product_add_to_cart_text', $text, $this );
    }
}

add_filter( 'woocommerce_product_class', function ( $classname, $product_type ) {
    if ( 'gift_card' === $product_type ) {
        return 'Sifero_Product_Gift_Card';
    }
    return $classname;
}, 10, 2 );

add_filter( 'product_type_selector', function ( $types ) {
    $types['gift_card'] = __( 'Gift card', 'sifero-gift-cards' );
    return $types;
} );

add_action( 'woocommerce_gift_card_add_to_cart', function () {
    wc_get_template( 'single-product/add-to-cart/simple.php' );
} );

Three decisions carry the exercise.

Parent class. Extending WC_Product is the honest choice, because it forces you to decide what the button says instead of inheriting WC_Product_Simple::add_to_cart_text() by accident. Extending WC_Product_Simple is also defensible when your type behaves like a simple product in every respect and only changes storage or labelling. Either way, be aware that WC_Product_Variation makes the second choice, which is why disabling a variation is a matter of variation_is_visible() rather than of rewriting the button text.

Re-applying the filter in the override. The tempting version of is_purchasable() returns the boolean directly. It works on your machine and breaks on a store running any plugin that filters purchasability, because the base method was the only caller of woocommerce_is_purchasable. Passing the combined result back through the filter keeps both conditions live, and the third argument is what lets a later callback identify the product.

Reusing the template. Loading single-product/add-to-cart/simple.php with wc_get_template() means a theme override of that template applies to your type too. Hand-written markup in the callback would render fine and then diverge from the store the first time someone customises the button.

For the questions: get_type() returns simple. The factory asks the data store for gift_card, passes it through woocommerce_product_class, and your filter is not registered, so it receives the computed WC_Product_Gift_Card. That class does not exist, and the factory's fallback assigns WC_Product_Simple. The line worth deleting to make the failure loud is the existence check inside WC_Product_Factory::get_product_classname(), which you cannot delete, so the practical answer is the one to build instead: assert get_class( wc_get_product( $id ) ) in your own test suite, because nothing in WooCommerce is going to tell you.

Progress

Mark this lesson done and it stays marked in this browser.