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.
| Class | Extends | Type string |
|---|---|---|
WC_Product | WC_Abstract_Legacy_Product | abstract, no instance |
WC_Product_Simple | WC_Product | simple |
WC_Product_Variable | WC_Product | variable |
WC_Product_Variation | WC_Product_Simple | variation |
WC_Product_Grouped | WC_Product | grouped |
WC_Product_External | WC_Product | external |
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:
- Turn the argument into an integer id. An object with an
IDproperty counts, and so does the global$postwhen the queried post type isproduct. - Ask the product data store for the type string.
- Run that string through the
woocommerce_product_classfilter to get a class name. - 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.
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_cardfromget_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_purchasablefor 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.