Products and Product Types

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

A WooCommerce product is not one thing. It is a post with a type, and the type decides which class loads it, which fields the editor shows, how it is priced, whether it can go into a cart and what the storefront draws instead of the add-to-cart button. Four types ship with WooCommerce (simple, variable, grouped and external), two flags (virtual and downloadable) change what a simple product needs from the shop, a variation is a child of a variable product, and an extension can add a type of its own. This page covers all of them with the catalogue of the Pocket Coffee shop: the variable product "Single-origin beans" with 18 variations, the simple "Starter kit", a grouped "Brew at home", an external "Guest roaster box", a downloadable "Brewing guide" and a custom type, the "Tasting flight". It then goes into attributes, the fields of the classic product editor, the native variation galleries that WooCommerce 10.9.0 added, queries, the way each type behaves in the REST API, the Store API and WP-CLI, and what happened to the block product editor, which was deprecated in 10.9.0 and removed in 11.0.0.

The page was written against WooCommerce 11.2.1 (the stable release of 2026-10-09; 11.3.0-beta.1 followed on 2026-10-26) on WordPress 7.1.3, PHP 8.3.36 and MariaDB 11.8.9, and checked on 2026-10-27. Products work the same on every hosting plan and in the free plugin; subscription, bundle and booking products come from paid extensions that use the mechanism of the custom type section (see Subscriptions, Memberships and Bookings). Everything below was run on the lab store with the plugin pocket-coffee-products: the catalogue is created with CRUD objects and a WP-CLI command, the editor was driven in a headless Chromium, and the storefront, the REST API, the Store API and WP-CLI were called for each type. The CRUD classes themselves (getters, setters, data stores, meta) are the subject of CRUD Objects and Data Stores; the hooks used here follow Hooks: Actions and Filters; the storefront blocks that list products are in Product Collection and Blocks; the plugin skeleton is in Extension Development. PHP language details are in PHP classes and objects.

The product model

Types, classes and where a product is stored

WooCommerce stores a product as a row of wp_posts (post type product, or product_variation for a variation, with the parent in post_parent), its fields as rows of wp_postmeta, its type, categories, tags, global attributes and visibility flags as terms, and copies of some fields in lookup tables. HPOS changed that for orders only; products are still posts. The type is the term of the product_type taxonomy attached to the product, and wc_get_product() reads it to choose the class.

The classes WC_Data, WC_Abstract_Legacy_Product and WC_Product, the four core product classes and the custom WC_Product_Tasting_Flight below WC_Product, WC_Product_Variation extending WC_Product_Simple, and the data store each one uses
Type Class What it is How it is bought

simple

WC_Product_Simple

One purchasable item with its own price, stock, shipping data and SKU.

Add to cart; Store API add-item with the product id.

variable

WC_Product_Variable

A parent that holds attributes and a list of variations; the parent has no price of its own.

The shopper chooses attribute values; Store API add-item needs the variation attributes (woocommerce_rest_missing_attributes without them).

grouped

WC_Product_Grouped

A list of child products shown together, each with its own quantity.

Through the children; the group itself is not purchasable (woocommerce_rest_product_not_purchasable).

external

WC_Product_External

A product page whose button links to a URL on another site.

Never in the cart; the button submits to product_url.

variation

WC_Product_Variation (extends WC_Product_Simple)

One combination of attribute values of a variable product.

Through the parent; the cart line is the variation.

The slugs are the constants of Automattic\WooCommerce\Enums\ProductType (SIMPLE, VARIABLE, GROUPED, EXTERNAL, VARIATION); use them instead of string literals. ProductType::get_all() (added in 10.9.0) returns all five including the variation, which is not a type a merchant can choose; wc_get_product_types() returns the four selectable types as a slug and label map and is filterable, so it also contains the types that extensions registered (the filter is product_type_selector). Two flags change a simple product without changing its type: virtual (no shipping: the Shipping tab disappears and needs_shipping() is false) and downloadable (the product carries files). A product can be both, and the lab store has one.

The products of the lab catalogue, printed by a short snippet after the seed command ran:

<?php
// One line per Pocket Coffee product.
foreach ( array( 'PC-BEANS-PRD', 'PC-KIT-PRD', 'PC-GROUP-PRD', 'PC-GUEST-PRD', 'PC-GUIDE-PRD', 'PC-FLIGHT-PRD' ) as $sku ) {
	$id      = wc_get_product_id_by_sku( $sku );
	$product = wc_get_product( $id );
	$price   = html_entity_decode( wp_strip_all_tags( preg_replace( '/<span class="screen-reader-text">.*?<\/span>/', '', $product->get_price_html() ) ) );
	printf( "%d %-25s %-15s sku=%-14s price html: %s | purchasable: %s\n", $id, get_class( $product ), $product->get_type(), $product->get_sku(), $price, var_export( $product->is_purchasable(), true ) );
}
458 WC_Product_Variable       variable        sku=PC-BEANS-PRD   price html: €8.90 – €33.90 | purchasable: true
477 WC_Product_Simple         simple          sku=PC-KIT-PRD     price html: €24.00 | purchasable: true
478 WC_Product_Grouped        grouped         sku=PC-GROUP-PRD   price html: €8.90 – €24.00 | purchasable: false
479 WC_Product_External       external        sku=PC-GUEST-PRD   price html: €12.00 | purchasable: false
480 WC_Product_Simple         simple          sku=PC-GUIDE-PRD   price html: €4.00 | purchasable: true
481 WC_Product_Tasting_Flight tasting_flight  sku=PC-FLIGHT-PRD  price html: €12.00 | purchasable: true

The price shown for a grouped product is the range of its visible children and the group is not purchasable; the external product is not purchasable either, because its button leaves the shop. These are properties of the classes (is_purchasable() returns false), which is why the Store API refuses them. === Where each piece of data lives

Four panels: wp_posts rows of the Pocket Coffee products, wp_postmeta keys such as the SKU, price, stock and attribute choices, taxonomies for product type, categories, global attributes and visibility, and lookup tables filled on save or by Action Scheduler

Two consequences matter for code. First, never read or write the meta keys directly: the CRUD classes keep the lookup tables, the caches and the hooks consistent, and a direct update_post_meta( $id, '_price', ... ) skips all of them. The lookup table wc_product_meta_lookup is rewritten on save and is what the shop queries for price and stock filters. The attributes lookup table wc_product_attributes_lookup is updated by Action Scheduler jobs unless the option woocommerce_attribute_lookup_direct_updates is yes (it was no on the lab store): right after the seed command it had no rows and many woocommerce_run_product_attribute_lookup_update_callback actions were pending. The tool Regenerate the product attributes lookup table in WooCommerce > Status > Tools rebuilds it. Second, the product object cache: the feature product_instance_caching ("Cache Product Objects") is on by default for stores installed from 11.0.0 and is off for older stores that upgrade; with it on, product objects are kept in the object cache, which is one more reason to go through the CRUD classes.

Simple products

The Starter kit, a hand grinder plus 250 g of beans for 24.00 EUR, is the smallest useful product. The function that creates it is part of the catalogue file used by the whole page; the file is listed in full in the variable product section, and this is the part that builds the simple product:

function pocket_coffee_create_kit( string $sku_suffix = '' ): int {
	$product = new WC_Product_Simple();
	$product->set_name( 'Starter kit' );
	$product->set_sku( 'PC-KIT' . $sku_suffix );
	$product->set_regular_price( '24.00' );
	$product->set_manage_stock( true );
	$product->set_stock_quantity( 15 );
	$product->set_weight( '0.9' );
	$product->set_length( '24' );
	$product->set_width( '14' );
	$product->set_height( '10' );
	$product->set_category_ids( array( pocket_coffee_category( 'Equipment', 'equipment' ) ) );
	$product->set_status( 'publish' );
	return $product->save();
}

The fields every product has, in the order of the editor tabs, with the setter that writes them:

Tab Setters Notes

General

set_regular_price(), set_sale_price(), set_date_on_sale_from(), set_date_on_sale_to(), set_tax_status(), set_tax_class()

Prices are strings. get_price() is the active price (the sale price while a sale runs). The tax status is taxable, shipping or none; the tax class is a slug, empty for the standard rate.

Inventory

set_sku(), set_global_unique_id(), set_manage_stock(), set_stock_quantity(), set_stock_status(), set_backorders(), set_low_stock_amount(), set_sold_individually()

The stock status is instock, outofstock or onbackorder; backorders are no, notify or yes. The global unique id is the GTIN, UPC, EAN or ISBN field.

Shipping

set_weight(), set_length(), set_width(), set_height(), set_shipping_class_id()

Units come from the shop settings (the lab store prints pounds and inches). A virtual product has no shipping data.

Linked products

set_upsell_ids(), set_cross_sell_ids()

Upsells appear on the product page, cross-sells in the cart.

Attributes

set_attributes(), set_default_attributes()

See the attributes section.

Advanced

set_purchase_note(), set_menu_order(), set_reviews_allowed()

The purchase note goes into the order confirmation.

Sidebar

set_catalog_visibility(), set_featured(), set_category_ids(), set_tag_ids(), set_image_id(), set_gallery_image_ids()

The visibility values are visible, catalog, search and hidden (the constants of Automattic\WooCommerce\Enums\CatalogVisibility).

Setters and save() throw a WC_Data_Exception for invalid data (the output below shows a duplicate SKU, and the downloads section a refused file). The snippet below runs the day-to-day operations on the lab store: stock changes, a low-stock threshold, a scheduled sale, the visibility flags and a duplicate SKU.

<?php
// Stock, low stock and a scheduled sale on the Starter kit and one variation.
$variation_id = wc_get_product_id_by_sku( 'PC-ETH-WHL-250-PRD' );
$variation    = wc_get_product( $variation_id );
echo 'stock before: ', $variation->get_stock_quantity(), ' ', $variation->get_stock_status(), "\n";
wc_update_product_stock( $variation, 2, 'decrease' );
echo 'after decrease 2: ', wc_get_product( $variation_id )->get_stock_quantity(), "\n";
wc_update_product_stock( $variation_id, 20, 'set' );
echo 'after set 20: ', wc_get_product( $variation_id )->get_stock_quantity(), "\n";

$kit_id = wc_get_product_id_by_sku( 'PC-KIT-PRD' );
$kit    = wc_get_product( $kit_id );
$kit->set_low_stock_amount( 5 );
$kit->save();
echo 'low stock amount: ', wc_get_low_stock_amount( wc_get_product( $kit_id ) ), ' (shop default ', get_option( 'woocommerce_notify_low_stock_amount' ), ")\n";

$kit = wc_get_product( $kit_id );
$kit->set_sale_price( '19.00' );
$kit->set_date_on_sale_from( '2026-11-01' );
$kit->set_date_on_sale_to( '2026-11-30' );
$kit->save();
$kit = wc_get_product( $kit_id );
printf( "scheduled sale: price=%s regular=%s sale=%s on_sale=%s from=%s\n", $kit->get_price(), $kit->get_regular_price(), $kit->get_sale_price(), var_export( $kit->is_on_sale(), true ), $kit->get_date_on_sale_from() );
$kit->set_sale_price( '' );
$kit->set_date_on_sale_from( '' );
$kit->set_date_on_sale_to( '' );
$kit->set_featured( true );
$kit->set_catalog_visibility( 'search' );
$kit->save();
echo 'product_visibility terms: ', wp_json_encode( wp_get_object_terms( $kit_id, 'product_visibility', array( 'fields' => 'slugs' ) ) ), "\n";
$kit->set_featured( false );
$kit->set_catalog_visibility( 'visible' );
$kit->save();

try {
	$duplicate = new WC_Product_Simple();
	$duplicate->set_name( 'Duplicate' );
	$duplicate->set_sku( 'PC-KIT-PRD' );
	$duplicate->save();
} catch ( WC_Data_Exception $e ) {
	echo 'duplicate SKU: ', $e->getErrorCode(), ' | ', $e->getMessage(), "\n";
}
echo 'wc_get_product_id_by_sku: ', wc_get_product_id_by_sku( 'PC-ETH-WHL-250-PRD' ), "\n";
stock before: 20 instock
after decrease 2: 18
after set 20: 20
low stock amount: 5 (shop default 2)
scheduled sale: price=24.00 regular=24.00 sale=19.00 on_sale=false from=2026-11-01T00:00:00+00:00
product_visibility terms: ["exclude-from-catalog","featured"]
duplicate SKU: product_invalid_sku | Invalid or duplicated SKU.
wc_get_product_id_by_sku: 459

The details worth remembering are all in that output. wc_update_product_stock( $product, $quantity, $operation ) changes the stock in one database update (set, increase or decrease) and fires the stock hooks; setting stock_quantity and saving does not protect against two requests overlapping. A scheduled sale is stored with its dates but is_on_sale() stays false and get_price() keeps the regular price until the first day, which a recurring Action Scheduler action (woocommerce_scheduled_sales, registered with as_schedule_recurring_action() in the source of 11.2.1, since the cron jobs moved to Action Scheduler in 10.1) turns into the sale price. catalog_visibility and featured are not meta: they are terms of the product_visibility taxonomy (featured, exclude-from-catalog, exclude-from-search, outofstock and the rated-N terms), which is why a product with visibility search carries the term exclude-from-catalog. A SKU must be unique across products and variations; wc_get_product_id_by_sku() finds the owner, and the WC_Data_Exception has the code product_invalid_sku.

Attributes

An attribute describes a product (origin, grind, size). It can be a global attribute, a taxonomy shared by all products whose terms the shop can filter and archive, or a product-level attribute that exists only on one product. Both are objects of the class WC_Product_Attribute, put into the product with set_attributes():

Property Meaning

id

The attribute taxonomy id for a global attribute (wc_attribute_taxonomy_id_by_name( 'origin' )), 0 for a product-level one.

name

The taxonomy name (pa_origin) for a global attribute; the display label (Grind) for a product-level one.

options

Term ids for a global attribute, an array of strings for a product-level one.

position

Order in the editor and on the product page.

visible

Show the attribute on the Additional information tab.

variation

The attribute defines variations. Only variable products use it.

A global attribute is created with wc_create_attribute() and registered as the taxonomy pa_<slug> on the next request; the catalogue code registers it immediately so that it can add terms in the same run. The snippet shows what the function refuses and which types exist:

<?php
// What wc_create_attribute() refuses.
foreach ( array( 'Type', 'A very long attribute name that will not fit in the taxonomy', 'Origin' ) as $name ) {
	$result = wc_create_attribute( array( 'name' => $name ) );
	echo 'wc_create_attribute( ', $name, ' ): ', is_wp_error( $result ) ? $result->get_error_code() . ' | ' . $result->get_error_message() : 'created ' . $result, "\n";
}
echo 'slug limit in bytes: ', wc_get_attribute_slug_max_byte_length(), "\n";
echo 'attribute types: ', wp_json_encode( wc_get_attribute_types() ), "\n";
echo 'global attributes: ', implode( ', ', wp_list_pluck( wc_get_attribute_taxonomies(), 'attribute_name' ) ), "\n";
echo 'taxonomy of roast: ', wc_attribute_taxonomy_name( 'roast' ), ' (id ', wc_attribute_taxonomy_id_by_name( 'roast' ), ")\n";
wc_create_attribute( Type ): invalid_product_attribute_slug_reserved_name | Slug "type" is not allowed because it is a reserved term. Change it, please.
wc_create_attribute( A very long attribute name that will not fit in the taxonomy ): invalid_product_attribute_slug_too_long | Slug "a-very-long-attribute-name-that-will-not-fit-in-the-taxonomy" is too long. Please use a shorter slug.
wc_create_attribute( Origin ): invalid_product_attribute_slug_already_exists | Slug "origin" is already in use. Change it, please.
slug limit in bytes: 29
attribute types: {"select":"Text"}
global attributes: origin, roast
taxonomy of roast: pa_roast (id 2)

The slug limit is 29 bytes (WordPress limits taxonomy names to 32 and WooCommerce keeps three for pa_), some names are reserved because WordPress uses them as query variables (type is one; WooCommerce keeps a list), and an existing slug is refused. The only attribute type on the lab store is select. The type wc-visual (colour swatches, added behind the feature wc-visual-attribute) is off by default; 11.1.0 made WooCommerce create nine default colour terms when a visual attribute is created, and 11.0.0 refined the swatch editor.

The Pocket Coffee catalogue uses both kinds. Origin is global, so shoppers can filter the shop by origin; Roast level (pa_roast) is global and descriptive (it does not create variations); Grind and Size exist only on the beans. The keys differ in a way that can trip code: for a product-level attribute get_attributes() and the variation attribute meta use the sanitized name (grind), get_variation_attributes() uses the label (Grind), and the REST API returns the slug Grind for the product and grind for the variation:

attributes: [["Origin", "pa_origin", true], ["Grind", "Grind", true], ["Size", "Size", true], ["Roast level", "pa_roast", false]]
variation attributes in REST: [["Origin", "pa_origin", "Ethiopia Yirgacheffe"], ["Grind", "grind", "Whole bean"], ["Size", "size", "250 g"]]

Compare attributes by lower-cased sanitized names, not by label, when a product-level attribute is involved. The terms of global attributes are ordinary WordPress terms (wp_insert_term(), term_exists()); Products > Attributes in the admin manages the attributes and their terms.

Variable products and variations

A variable product is a parent with attributes flagged variation and one WC_Product_Variation per combination. The shopper chooses a value of each variation attribute; the matching variation supplies the price, stock, SKU, image and shipping data. Pocket Coffee sells one product with three variation attributes, 3 origins x 3 grinds x 2 sizes = 18 variations.

Three panels, one per origin, each a grid of three grinds by two sizes with the SKU and price of each variation: 8.90 and 29.90 euro, and 9.90 and 33.90 for Ethiopia

The catalogue file follows. pocket_coffee_create_beans() builds the attributes, the parent and the 18 variations; the other functions create the remaining products and are used in the next sections. SKUs get a suffix because they must be unique in the whole catalogue (the lab store appends -PRD).

<?php
/**
 * Pocket Coffee catalogue built with the CRUD classes: one function per product type.
 *
 * @package PocketCoffee\Products
 */

defined( 'ABSPATH' ) || exit;

/**
 * A global attribute (a taxonomy named pa_<slug>) with its terms.
 *
 * @param string   $label Attribute label, for example "Roast level".
 * @param string   $slug  Attribute slug without the pa_ prefix.
 * @param string[] $terms Term names.
 * @return array<string,string> Term slugs keyed by term name.
 * @throws RuntimeException When the attribute cannot be created.
 */
function pocket_coffee_global_attribute( string $label, string $slug, array $terms ): array {
	if ( ! wc_attribute_taxonomy_id_by_name( $slug ) ) {
		$result = wc_create_attribute(
			array(
				'name'         => $label,
				'slug'         => $slug,
				'type'         => 'select',
				'order_by'     => 'menu_order',
				'has_archives' => false,
			)
		);
		if ( is_wp_error( $result ) ) {
			throw new RuntimeException( esc_html( $result->get_error_message() ) );
		}
		// The taxonomy is registered on the next request; register it now so terms can be added.
		register_taxonomy( wc_attribute_taxonomy_name( $slug ), array( 'product' ) );
	}

	$taxonomy = wc_attribute_taxonomy_name( $slug );
	$slugs    = array();
	foreach ( $terms as $name ) {
		$existing       = term_exists( $name, $taxonomy );
		$term_id        = $existing ? (int) $existing['term_id'] : (int) wp_insert_term( $name, $taxonomy )['term_id'];
		$slugs[ $name ] = get_term( $term_id, $taxonomy )->slug;
	}
	return $slugs;
}

/**
 * Term ids of a taxonomy for a list of slugs.
 *
 * @param string   $taxonomy Taxonomy name, for example pa_origin.
 * @param string[] $slugs    Term slugs.
 * @return int[]
 */
function pocket_coffee_term_ids( string $taxonomy, array $slugs ): array {
	$terms = get_terms(
		array(
			'taxonomy'   => $taxonomy,
			'slug'       => array_values( $slugs ),
			'hide_empty' => false,
		)
	);
	return array_map( 'intval', wp_list_pluck( $terms, 'term_id' ) );
}

/**
 * A product attribute. An id above 0 is a global attribute; id 0 is a product-level (custom) attribute.
 *
 * @param int              $id        Attribute taxonomy id, or 0.
 * @param string           $name      Taxonomy name (pa_origin) or the label of a custom attribute.
 * @param array<int,mixed> $options   Term ids for a global attribute, values for a custom one.
 * @param int              $position  Position in the list.
 * @param bool             $variation Whether the attribute defines variations.
 * @return WC_Product_Attribute
 */
function pocket_coffee_attribute( int $id, string $name, array $options, int $position, bool $variation ): WC_Product_Attribute {
	$attribute = new WC_Product_Attribute();
	$attribute->set_id( $id );
	$attribute->set_name( $name );
	$attribute->set_options( $options );
	$attribute->set_position( $position );
	$attribute->set_visible( true );
	$attribute->set_variation( $variation );
	return $attribute;
}

/**
 * A product category by slug, created when missing.
 *
 * @param string $name Category name.
 * @param string $slug Category slug.
 * @return int Term id.
 */
function pocket_coffee_category( string $name, string $slug ): int {
	$term = get_term_by( 'slug', $slug, 'product_cat' );
	if ( $term ) {
		return (int) $term->term_id;
	}
	return (int) wp_insert_term( $name, 'product_cat', array( 'slug' => $slug ) )['term_id'];
}

/**
 * Variable product "Single-origin beans": 3 origins x 3 grinds x 2 sizes = 18 variations.
 *
 * @param string $sku_suffix Appended to every SKU (SKUs are unique across the catalogue).
 * @return int Product id.
 */
function pocket_coffee_create_beans( string $sku_suffix = '' ): int {
	$origins = pocket_coffee_global_attribute( 'Origin', 'origin', array( 'Ethiopia Yirgacheffe', 'Colombia Huila', 'Brazil Cerrado' ) );
	$roast   = pocket_coffee_global_attribute( 'Roast level', 'roast', array( 'Light', 'Medium', 'Dark' ) );
	$codes   = array(
		'ETH' => 'Ethiopia Yirgacheffe',
		'COL' => 'Colombia Huila',
		'BRA' => 'Brazil Cerrado',
	);
	$grinds  = array(
		'WHL' => 'Whole bean',
		'ESP' => 'Espresso',
		'FIL' => 'Filter',
	);
	$sizes   = array(
		'250' => array(
			'label'  => '250 g',
			'price'  => 8.90,
			'weight' => '0.25',
			'units'  => 1,
		),
		'1KG' => array(
			'label'  => '1 kg',
			'price'  => 29.90,
			'weight' => '1',
			'units'  => 4,
		),
	);

	$product = new WC_Product_Variable();
	$product->set_name( 'Single-origin beans' );
	$product->set_sku( 'PC-BEANS' . $sku_suffix );
	$product->set_status( 'publish' );
	$product->set_short_description( 'Specialty beans roasted in small batches.' );
	$product->set_category_ids( array( pocket_coffee_category( 'Beans', 'beans' ) ) );
	$product->set_attributes(
		array(
			pocket_coffee_attribute( wc_attribute_taxonomy_id_by_name( 'origin' ), 'pa_origin', pocket_coffee_term_ids( 'pa_origin', $origins ), 0, true ),
			pocket_coffee_attribute( 0, 'Grind', array_values( $grinds ), 1, true ),
			pocket_coffee_attribute( 0, 'Size', wp_list_pluck( $sizes, 'label' ), 2, true ),
			// Describes the product without creating variations.
			pocket_coffee_attribute( wc_attribute_taxonomy_id_by_name( 'roast' ), 'pa_roast', pocket_coffee_term_ids( 'pa_roast', array( $roast['Medium'] ) ), 3, false ),
		)
	);
	$product->set_default_attributes(
		array(
			'pa_origin' => $origins['Ethiopia Yirgacheffe'],
			'grind'     => 'Whole bean',
			'size'      => '250 g',
		)
	);
	$product_id = $product->save();

	$position = 0;
	foreach ( $codes as $origin_code => $origin_name ) {
		foreach ( $grinds as $grind_code => $grind_name ) {
			foreach ( $sizes as $size_code => $size ) {
				// Ethiopia costs 1.00 more per 250 g.
				$surcharge = 'ETH' === $origin_code ? 1.00 * $size['units'] : 0.0;

				$variation = new WC_Product_Variation();
				$variation->set_parent_id( $product_id );
				$variation->set_attributes(
					array(
						'pa_origin' => $origins[ $origin_name ],
						'grind'     => $grind_name,
						'size'      => $size['label'],
					)
				);
				$variation->set_sku( sprintf( 'PC-%s-%s-%s%s', $origin_code, $grind_code, $size_code, $sku_suffix ) );
				$variation->set_regular_price( (string) ( $size['price'] + $surcharge ) );
				$variation->set_weight( $size['weight'] );
				$variation->set_manage_stock( true );
				$variation->set_stock_quantity( 20 );
				$variation->set_menu_order( ++$position ); // The editor lists variations by menu order, then newest first.
				$variation->set_status( 'publish' );
				$variation->save();
			}
		}
	}

	return $product_id;
}

/**
 * Simple product "Starter kit": hand grinder plus 250 g of beans for 24.00 EUR.
 *
 * @param string $sku_suffix Appended to the SKU.
 * @return int Product id.
 */
function pocket_coffee_create_kit( string $sku_suffix = '' ): int {
	$product = new WC_Product_Simple();
	$product->set_name( 'Starter kit' );
	$product->set_sku( 'PC-KIT' . $sku_suffix );
	$product->set_regular_price( '24.00' );
	$product->set_manage_stock( true );
	$product->set_stock_quantity( 15 );
	$product->set_weight( '0.9' );
	$product->set_length( '24' );
	$product->set_width( '14' );
	$product->set_height( '10' );
	$product->set_category_ids( array( pocket_coffee_category( 'Equipment', 'equipment' ) ) );
	$product->set_status( 'publish' );
	return $product->save();
}

/**
 * Grouped product: no price of its own, it lists purchasable children.
 *
 * @param int[]  $children   Child product ids.
 * @param string $sku_suffix Appended to the SKU.
 * @return int Product id.
 */
function pocket_coffee_create_group( array $children, string $sku_suffix = '' ): int {
	$product = new WC_Product_Grouped();
	$product->set_name( 'Brew at home' );
	$product->set_sku( 'PC-GROUP' . $sku_suffix );
	$product->set_children( $children );
	$product->set_status( 'publish' );
	return $product->save();
}

/**
 * External (affiliate) product: the button links out and WooCommerce never sells it.
 *
 * @param string $sku_suffix Appended to the SKU.
 * @return int Product id.
 */
function pocket_coffee_create_external( string $sku_suffix = '' ): int {
	$product = new WC_Product_External();
	$product->set_name( 'Guest roaster box' );
	$product->set_sku( 'PC-GUEST' . $sku_suffix );
	$product->set_regular_price( '12.00' );
	$product->set_product_url( 'https://roaster.example.test/shop/guest-box' );
	$product->set_button_text( 'Buy at the roaster' );
	$product->set_status( 'publish' );
	return $product->save();
}

/**
 * Virtual and downloadable product: no shipping, one PDF file.
 *
 * @param string $file_url   URL of a file in an approved download directory.
 * @param string $sku_suffix Appended to the SKU.
 * @return int Product id.
 */
function pocket_coffee_create_guide( string $file_url, string $sku_suffix = '' ): int {
	$download = new WC_Product_Download();
	$download->set_id( wp_generate_uuid4() );
	$download->set_name( 'Brewing guide' );
	$download->set_file( $file_url );

	$product = new WC_Product_Simple();
	$product->set_name( 'Brewing guide (PDF)' );
	$product->set_sku( 'PC-GUIDE' . $sku_suffix );
	$product->set_regular_price( '4.00' );
	$product->set_virtual( true );
	$product->set_downloadable( true );
	$product->set_downloads( array( $download ) );
	$product->set_download_limit( 3 );   // -1 means unlimited.
	$product->set_download_expiry( 30 ); // Days; -1 means never.
	$product->set_status( 'publish' );
	return $product->save();
}

/**
 * Custom product type: three 50 g sample bags.
 *
 * @param string $sku_suffix Appended to the SKU.
 * @return int Product id.
 */
function pocket_coffee_create_flight( string $sku_suffix = '' ): int {
	$product = new WC_Product_Tasting_Flight();
	$product->set_name( 'Tasting flight' );
	$product->set_sku( 'PC-FLIGHT' . $sku_suffix );
	$product->set_regular_price( '12.00' );
	$product->set_origins( array( 'ethiopia-yirgacheffe', 'colombia-huila', 'brazil-cerrado' ) );
	$product->set_status( 'publish' );
	return $product->save();
}

The WP-CLI command of the plugin calls these functions, writes the placeholder PDF of the downloadable product into the protected folder and prints the product ids; its cleanup subcommand deletes every product whose SKU contains the suffix.

<?php
/**
 * WP-CLI commands used to run the examples: wp pocket-coffee-products <seed|inspect|cleanup>.
 *
 * @package PocketCoffee\Products
 */

defined( 'ABSPATH' ) || exit;

require_once __DIR__ . '/catalog.php';

/**
 * Pocket Coffee catalogue helpers.
 */
class PC_Products_CLI {

	const SUFFIX = '-PRD';

	/**
	 * Create the catalogue.
	 */
	public function seed() {
		$beans    = pocket_coffee_create_beans( self::SUFFIX );
		$kit      = pocket_coffee_create_kit( self::SUFFIX );
		$group    = pocket_coffee_create_group( array( $kit, $beans ), self::SUFFIX );
		$external = pocket_coffee_create_external( self::SUFFIX );

		$dir = wp_upload_dir();
		// woocommerce_uploads is the protected folder WooCommerce approves for downloads by default.
		wp_mkdir_p( $dir['basedir'] . '/woocommerce_uploads' );
		file_put_contents( $dir['basedir'] . '/woocommerce_uploads/pocket-coffee-brewing-guide.pdf', "%PDF-1.4\n% Pocket Coffee brewing guide placeholder\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		$guide  = pocket_coffee_create_guide( $dir['baseurl'] . '/woocommerce_uploads/pocket-coffee-brewing-guide.pdf', self::SUFFIX );
		$flight = pocket_coffee_create_flight( self::SUFFIX );

		WP_CLI::log( wp_json_encode( compact( 'beans', 'kit', 'group', 'external', 'guide', 'flight' ) ) );
	}

	/**
	 * Remove everything created by seed.
	 */
	public function cleanup() {
		$ids = wc_get_products(
			array(
				'sku'    => self::SUFFIX,
				'limit'  => -1,
				'return' => 'ids',
				'status' => array( 'publish', 'draft', 'private' ),
			)
		);
		foreach ( $ids as $id ) {
			$product = wc_get_product( $id );
			if ( $product ) {
				$product->delete( true );
			}
		}
		// The rejected save of an unapproved folder left a disabled rule behind; remove it.
		$register = wc_get_container()->get( \Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register::class );
		$rule     = $register->get_by_url( wp_upload_dir()['baseurl'] . '/pocket-coffee/' );
		if ( $rule ) {
			$register->delete_by_id( $rule->get_id() );
		}
		WP_CLI::success( 'Deleted ' . count( $ids ) . ' products.' );
	}
}

WP_CLI::add_command( 'pocket-coffee-products', 'PC_Products_CLI' );
{"beans":458,"kit":477,"group":478,"external":479,"guide":480,"flight":481}

What the parent and a variation report

<?php
// The variable product as the CRUD objects report it.
$product = wc_get_product( wc_get_product_id_by_sku( 'PC-BEANS-PRD' ) );
echo get_class( $product ), ' type=', $product->get_type(), ' children=', count( $product->get_children() ), "\n";
echo 'price html: ', html_entity_decode( wp_strip_all_tags( preg_replace( '/<span class="screen-reader-text">.*?<\/span>/', '', $product->get_price_html() ) ) ), "\n";
echo 'min and max price: ', $product->get_variation_price( 'min' ), ' ', $product->get_variation_price( 'max' ), "\n";
foreach ( $product->get_attributes() as $key => $attribute ) {
	printf( "attribute %s: id=%d variation=%s visible=%s taxonomy=%s options=%s\n", $key, $attribute->get_id(), var_export( $attribute->get_variation(), true ), var_export( $attribute->get_visible(), true ), var_export( $attribute->is_taxonomy(), true ), wp_json_encode( $attribute->get_options() ) );
}
echo 'variation attributes: ', wp_json_encode( $product->get_variation_attributes() ), "\n";
echo 'default attributes: ', wp_json_encode( $product->get_default_attributes() ), "\n";
$variation = wc_get_product( $product->get_children()[0] );
echo get_class( $variation ), ' sku=', $variation->get_sku(), ' attributes=', wp_json_encode( $variation->get_attributes() ), ' price=', $variation->get_price(), "\n";
$data = $product->get_available_variation( $variation );
unset( $data['image'], $data['gallery_images_html'], $data['price_html'], $data['availability_html'], $data['dimensions_html'], $data['weight_html'] );
echo wp_json_encode( $data, JSON_PRETTY_PRINT ), "\n";
WC_Product_Variable type=variable children=18
price html: €8.90 – €33.90
min and max price: 8.90 33.90
attribute pa_origin: id=1 variation=true visible=true taxonomy=true options=[23,22,21]
attribute grind: id=0 variation=true visible=true taxonomy=false options=["Whole bean","Espresso","Filter"]
attribute size: id=0 variation=true visible=true taxonomy=false options=["250 g","1 kg"]
attribute pa_roast: id=2 variation=false visible=true taxonomy=true options=[28]
variation attributes: {"pa_origin":{"0":"ethiopia-yirgacheffe","6":"colombia-huila","12":"brazil-cerrado"},"Grind":["Whole bean","Espresso","Filter"],"Size":["250 g","1 kg"]}
default attributes: {"pa_origin":"ethiopia-yirgacheffe","grind":"Whole bean","size":"250 g"}
WC_Product_Variation sku=PC-ETH-WHL-250-PRD attributes={"pa_origin":"ethiopia-yirgacheffe","grind":"Whole bean","size":"250 g"} price=9.9
{
    "attributes": {
        "attribute_pa_origin": "ethiopia-yirgacheffe",
        "attribute_grind": "Whole bean",
        "attribute_size": "250 g"
    },
    "backorders_allowed": false,
    "dimensions": {
        "length": "",
        "width": "",
        "height": ""
    },
    "display_price": 9.9,
    "display_regular_price": 9.9,
    "gallery_image_ids": [
        354,
        355
    ],
    "image_id": 353,
    "is_downloadable": false,
    "is_in_stock": true,
    "is_purchasable": true,
    "is_sold_individually": "no",
    "is_virtual": false,
    "max_qty": 20,
    "min_qty": 1,
    "sku": "PC-ETH-WHL-250-PRD",
    "variation_description": "",
    "variation_id": 459,
    "variation_is_active": true,
    "variation_is_visible": true,
    "weight": "0.25",
    "pc_roast_batch": "B-2026-10-A"
}

Reading the output from the top: the parent reports a price range, because get_price_html() asks get_variation_price() for the minimum and maximum of its variations; the attribute list shows the two kinds of attribute side by side, with the description-only pa_roast not flagged for variations; the default attributes are what the product page preselects (the shopper lands on Ethiopia, whole bean, 250 g); and get_available_variation() is the array that WooCommerce prints into the data-product_variations attribute of the storefront form and that the front-end script reads when the shopper changes a choice. Extensions add their own keys to it with the filter woocommerce_available_variation, as the Pocket Coffee roast batch does later (the key pc_roast_batch in the output).

What a variation inherits matters when you write code against it:

  • Name and description. A variation has no title of its own; its name is the parent’s title plus the attribute values. variation_description is its own optional text.

  • Tax class. The stored meta is parent, and get_tax_class() in the view context resolves it to the parent’s class.

  • Stock. A variation can manage its own stock (the lab variations hold 20 units each) or leave it to the parent (get_manage_stock() then returns parent).

  • Weight, dimensions and shipping class. An empty field falls back to the parent’s value; the beans set the weight on every variation (0.25 and 1) and nothing on the parent.

  • Image. The variation’s own image, otherwise the parent’s. Galleries are the subject of a later section.

  • Price. Always its own. The parent keeps a derived price index: WC_Product_Variable::sync() runs when a variation is saved and recomputes the minimum and maximum, which the shop uses to filter and sort and which are cached in the transient wc_var_prices_<id>. A plugin that changes prices on the fly through the woocommerce_product_variation_get_price filters adds its own factor to the cache key with woocommerce_get_variation_prices_hash, or shoppers see a stale price.

An empty attribute value on a variation means "any": the variation matches every value of that attribute, and the editor writes it when a merchant chooses "Any Grind…​" in the variation’s drop-down.

Many variations

The editor shows 15 variations per page (the filter woocommerce_admin_meta_boxes_variations_per_page; the lab’s 18 variations gave a pager "18 variations" and 15 rows), and saving a page of variations also rewrites the menu_order of the rows it shows. A script that does not set set_menu_order() gets variations with menu_order 0, which the editor lists newest first; the catalogue code increments the order so that the editor lists them in the order they were created. On the storefront the variation form carries all variations in the page up to the threshold of the filter woocommerce_ajax_variation_threshold (30 by default; the lab’s 18 are inline) and above it fetches the matching variation by AJAX after each choice. The Store API product lists the variation ids and, per variation, the attribute values (18 entries for the beans).

The storefront

The default selections of the product page of the beans are the default attributes, and the price and gallery change with the choice. This is what a headless browser read from the live page after the variation gallery of the first variation was set (the next section) and the roast date of the beans was saved:

default selection: ["attribute_pa_origin=ethiopia-yirgacheffe","attribute_grind=Whole bean","attribute_size=250 g"]
gallery images at load: ["1f50d.svg","pc-prd-bag.png","pc-prd-bag.png","pc-prd-beans.png","pc-prd-cup.png","pc-prd-bag-100x100.png","pc-prd-beans-100x100.png","pc-prd-cup-100x100.png"]
price shown: €9.90
roast badge: ["Roasted on October 20, 2026"]
tabs: ["Additional information","Brewing notes","Reviews (0)"]
after choosing Colombia: ["1f50d.svg","woocommerce-placeholder.webp"] | price: €8.90

Choosing another origin replaced the gallery of the first variation by the placeholder, because the other variations have no images, and the price by 8.90. The block theme of the lab renders this form with the Add to Cart Form block, which prints the classic variation form; the newer Add to Cart + Options block, whose 10.8.0 changelog mentions the quantity inputs of third-party product types, is the alternative in block templates.

Grouped products

A grouped product is a list. It has a name, an image, a description and a set of children; each child has its own price, stock and quantity input, and the shopper adds some of them in one step. The group has no price of its own: the page prints the range of the visible children, and the group is not purchasable. The Pocket Coffee group "Brew at home" lists the starter kit and the beans (a child can itself be variable; its row then offers Select options and not a quantity). The creation code is pocket_coffee_create_group() in the catalogue listing above:

$product = new WC_Product_Grouped();
$product->set_name( 'Brew at home' );
$product->set_sku( 'PC-GROUP' . $sku_suffix );
$product->set_children( $children );
$product->set_status( 'publish' );
return $product->save();

The product page of the group printed form class="cart grouped_form", and the Store API refused the group with woocommerce_rest_product_not_purchasable (see the Store API section). Children are chosen by id, so deleting a child silently removes it from the group; a child that is a draft or private is hidden from shoppers. Do not use the stored price of a grouped product for anything but sorting: on the lab store the copy held 24.00 for a group whose children cost 8.90 to 24.00 until sync_price() was called on its data store, and the displayed range was right all the time because it is computed from the children when the page is drawn.

External products

An external (or affiliate) product is a page in the catalogue whose button sends the shopper elsewhere. It has a price, an image, a description and a gallery, no stock and no shipping data, and two fields of its own: product_url and button_text. The page of "Guest roaster box" printed a form whose action is https://roaster.example.test/shop/guest-box with method="get" and a button labelled Buy at the roaster; the product never enters the cart, so it has no order, no tax and no stock. It was not purchasable (purchasable false in REST).

$product = new WC_Product_External();
$product->set_name( 'Guest roaster box' );
$product->set_sku( 'PC-GUEST' . $sku_suffix );
$product->set_regular_price( '12.00' );
$product->set_product_url( 'https://roaster.example.test/shop/guest-box' );
$product->set_button_text( 'Buy at the roaster' );
$product->set_status( 'publish' );
return $product->save();

The REST API exposes external_url and button_text for the type and the product CSV importer maps them (the columns "External URL" and "Button text"). A merchant who needs tracking parameters appends them to the URL.

Virtual and downloadable products

virtual means "nothing to ship": needs_shipping() is false (the Starter kit returns true and the Brewing guide false), the Shipping tab is hidden for the product, and a cart that holds only virtual products needs no shipping. downloadable means the product carries files that a customer receives after payment. The two are independent switches of a simple product (and of variations), and a download that is also virtual is the usual combination.

Files and approved directories

A file is a WC_Product_Download: an id, a name and a file path or URL. Files are stored on the product as an array keyed by the id, together with a download limit and an expiry in days (-1 means unlimited and never). The catalogue code creates the Brewing guide with one file, a limit of 3 downloads and a 30-day expiry:

$download = new WC_Product_Download();
$download->set_id( wp_generate_uuid4() );
$download->set_name( 'Brewing guide' );
$download->set_file( $file_url );

$product = new WC_Product_Simple();
$product->set_name( 'Brewing guide (PDF)' );
$product->set_sku( 'PC-GUIDE' . $sku_suffix );
$product->set_regular_price( '4.00' );
$product->set_virtual( true );
$product->set_downloadable( true );
$product->set_downloads( array( $download ) );
$product->set_download_limit( 3 );
$product->set_download_expiry( 30 );
$product->set_status( 'publish' );
return $product->save();

WooCommerce only accepts a file that lives in an approved directory. The feature is "Approved Download Directories" and its mode is enabled by default. The rules are listed under WooCommerce > Settings > Products > Approved download directories (tab=products&section=download_urls), where the list offers Add New, Stop Enforcing Rules and bulk actions. A new store approves its protected woocommerce_uploads folder, once as a path and once as a URL. A file anywhere else is refused when it is set on the product, and the refused location is recorded as a disabled rule for an administrator to review, which a script should remove when the refusal was only a test:

<?php
// Approved download directories: a file outside the approved folders is refused.
use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register;

$register = wc_get_container()->get( Register::class );
echo 'mode: ', $register->get_mode(), "\n";

$download = new WC_Product_Download();
$download->set_id( wp_generate_uuid4() );
$download->set_name( 'Remote guide' );
$download->set_file( 'https://cdn.example.test/files/guide.pdf' );
try {
	( new WC_Product_Simple() )->set_downloads( array( $download ) );
	echo "accepted\n";
} catch ( WC_Data_Exception $e ) {
	echo $e->getErrorCode(), ' | ', wp_strip_all_tags( $e->getMessage() ), "\n";
}

foreach ( $register->list( array( 'per_page' => 10 ) )['approved_directories'] as $rule ) {
	echo 'rule ', $rule->get_id(), ' ', $rule->get_url(), ' enabled=', var_export( $rule->is_enabled(), true ), "\n";
}
$uploads = wp_upload_dir()['baseurl'];
echo 'woocommerce_uploads approved: ', var_export( $register->is_valid_path( $uploads . '/woocommerce_uploads/pocket-coffee-brewing-guide.pdf' ), true ), "\n";
echo 'uploads/pocket-coffee approved: ', var_export( $register->is_valid_path( $uploads . '/pocket-coffee/guide.pdf' ), true ), "\n";

// The refused folder left a disabled rule behind; remove it.
$rule = $register->get_by_url( 'https://cdn.example.test/files/' );
if ( $rule ) {
	$register->delete_by_id( $rule->get_id() );
}
mode: enabled
product_invalid_download | The downloadable file https://cdn.example.test/files/guide.pdf cannot be used as it does not exist on the server, or is not located within an approved directory. Please contact a site administrator for help. Learn more.
rule 1 file:///var/www/html/wp-content/uploads/woocommerce_uploads/ enabled=true
rule 2 http://localhost:8081/wp-content/uploads/woocommerce_uploads/ enabled=true
rule 6 https://cdn.example.test/files/ enabled=false
woocommerce_uploads approved: true
uploads/pocket-coffee approved: false

The lab’s first attempt stored the PDF under uploads/pocket-coffee/ and failed the same way, which is why the catalogue command writes it into woocommerce_uploads. The class Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register (an internal class: add_approved_directory( $url, $enabled ), delete_by_id(), is_valid_path(), get_mode()) manages the rules from code; a cleaner route for a deployment is to approve the folder once in the settings screen.

What happens when the customer pays

A paid order (completed, or processing when the setting "Grant access to downloadable products after payment" is on) grants download permissions: one row per product and order in woocommerce_downloadable_product_permissions with the remaining downloads, the expiry and a count. The link in the order email and the account page carries the order key and two hashes; the file is delivered by WooCommerce with the method of the setting woocommerce_file_download_method ("Force downloads", which streams through PHP, "X-Accel-Redirect/X-Sendfile" or "Redirect only (Insecure)", under Settings > Products > Downloadable products; the filter woocommerce_file_download_method overrides it per product and file). The snippet creates a completed order for the guide and reads the permission:

<?php
// A completed order creates the download permission of the Brewing guide.
global $wpdb;
$order = wc_create_order( array( 'status' => 'pending' ) );
$order->set_billing_email( 'ada@example.test' );
$order->add_product( wc_get_product( wc_get_product_id_by_sku( 'PC-GUIDE-PRD' ) ), 1 );
$order->calculate_totals();
$order->set_status( 'completed' );
$order->save();
echo 'order status=', $order->get_status(), ' has_downloadable_item=', var_export( $order->has_downloadable_item(), true ), "\n";

$rows = $wpdb->get_results(
	$wpdb->prepare(
		"SELECT product_id, downloads_remaining, access_expires, download_count FROM {$wpdb->prefix}woocommerce_downloadable_product_permissions WHERE order_id = %d",
		$order->get_id()
	)
);
foreach ( $rows as $row ) {
	echo wp_json_encode( $row ), "\n";
}
foreach ( $order->get_downloadable_items() as $item ) {
	$query = preg_replace( '/(uid|key)=[0-9a-f-]+/', '$1=<hash>', wp_parse_url( $item['download_url'], PHP_URL_QUERY ) );
	echo 'download link query: ', $query, ' remaining=', $item['downloads_remaining'], ' expires=', $item['access_expires'], "\n";
}
$order->delete( true );
order status=completed has_downloadable_item=true
{"product_id":"480","downloads_remaining":"3","access_expires":"2026-11-26 00:00:00","download_count":"0"}
download link query: download_file=480&order=wc_order_TC1d8UlGN5tQ8&uid=<hash>&key=<hash> remaining=3 expires=2026-11-26T00:00:00+00:00

The limit and the expiry of the product became downloads_remaining 3 and access_expires 30 days later. The filter woocommerce_downloadable_file_permission changes the permission before it is stored; the Downloadable product permissions box of the order screen, where an administrator grants access by hand, is hidden by default on the order edit screen since 11.1.0 and can be shown through Screen Options. The table wc_download_log keeps a log of downloads and Analytics has a Downloads report.

Custom fields and tabs in the product editor

The classic product editor (the WordPress post editor with the Product data meta box) is the only product editor of WooCommerce 11.2.1, and it is extended with PHP hooks. The meta box is a set of tabs; each tab prints a block of fields and fires an action at its end, and the whole box fires one more:

Hook Where the output lands

woocommerce_product_options_general_product_data

End of the General tab (the Pocket Coffee roast date and flight fields).

woocommerce_product_options_pricing, woocommerce_product_options_tax, woocommerce_product_options_downloads, woocommerce_product_options_external

Inside the price group, the tax group, the downloads group and the external product group of the General tab.

woocommerce_product_options_sku, woocommerce_product_options_stock, woocommerce_product_options_stock_fields, woocommerce_product_options_stock_status, woocommerce_product_options_sold_individually, woocommerce_product_options_inventory_product_data

Inside and at the end of the Inventory tab.

woocommerce_product_options_shipping, woocommerce_product_options_dimensions, woocommerce_product_options_shipping_product_data

The Shipping tab.

woocommerce_product_options_related, woocommerce_product_options_attributes, woocommerce_product_options_advanced, woocommerce_product_options_reviews

Linked products, Attributes and Advanced tabs.

woocommerce_product_data_tabs (filter), woocommerce_product_data_panels (action)

Add a tab: the filter adds the tab (label, target, class, priority), the action prints the panel.

product_type_options (filter)

The checkboxes next to the type drop-down (Virtual, Downloadable).

woocommerce_variation_options, woocommerce_variation_options_pricing, woocommerce_variation_options_inventory, woocommerce_variation_options_dimensions, woocommerce_variation_options_tax, woocommerce_variation_options_download, woocommerce_product_after_variable_attributes

Variation rows.

Fields are printed with the helpers of includes/admin/wc-meta-box-functions.php: woocommerce_wp_text_input(), woocommerce_wp_hidden_input(), woocommerce_wp_textarea_input(), woocommerce_wp_checkbox(), woocommerce_wp_select(), woocommerce_wp_radio() and woocommerce_wp_note(). Each takes the field array (id, label, value, type, class, wrapper_class, desc_tip, description, custom_attributes, data_type with the values price, decimal, stock and url for the matching input formatting) and, in 11.2.1, an optional second argument: the WC_Data object to read the value from. The visibility classes are the contract with the editor script: an element with show_if_simple is shown for simple products, one with hide_if_virtual is hidden for virtual ones, and the class list of a group decides which types see it.

The two hooks that save are different on purpose. woocommerce_admin_process_product_object( $product ) receives the product object before WooCommerce saves it, so a callback only sets properties and meta and writes nothing. woocommerce_process_product_meta is the save_post action that WooCommerce hooks its own meta box savers to (priorities 10 and 20), so a callback at a later priority runs after the save, with the post id; woocommerce_process_product_meta_{type} fires inside the product data save, also with the post id. A callback there should load the product and call save() or save_meta_data(). WooCommerce checks its own nonce (woocommerce_meta_nonce), the post id and the edit_post capability before it runs any of these, so the callbacks need neither.

The roast date

The Pocket Coffee roast date is a plain meta key, roast_date, shown as a date input on simple, variable and flight products, saved with the object hook and printed under the price. The class of the badge reads the options of the settings tab of Settings and Admin Pages: it hides itself when the checkbox is off, uses the label, switches between the date and the days since the roast, and adds "past peak" after the freshness window.

<?php
/**
 * A "Roast date" field for simple, variable and tasting flight products.
 *
 * @package PocketCoffee\Products
 */

defined( 'ABSPATH' ) || exit;

// Field in the General tab. woocommerce_wp_text_input() prints the label, input and tooltip.
add_action(
	'woocommerce_product_options_general_product_data',
	function () {
		global $product_object;

		echo '<div class="options_group show_if_simple show_if_variable show_if_tasting_flight">';
		woocommerce_wp_text_input(
			array(
				'id'          => 'roast_date',
				'label'       => __( 'Roast date', 'pocket-coffee-products' ),
				'type'        => 'date',
				'value'       => $product_object ? $product_object->get_meta( 'roast_date', true ) : '',
				'desc_tip'    => true,
				'description' => __( 'Shown as a badge on the product page.', 'pocket-coffee-products' ),
			)
		);
		echo '</div>';
	}
);

// Save the value on the product object. WooCommerce calls save() afterwards, so no explicit save is needed.
add_action(
	'woocommerce_admin_process_product_object',
	function ( WC_Product $product ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- the nonce is checked by WooCommerce before this action.
		$value = isset( $_POST['roast_date'] ) ? wc_clean( wp_unslash( $_POST['roast_date'] ) ) : '';
		if ( '' === $value || false !== strtotime( $value ) ) {
			$product->update_meta_data( 'roast_date', $value );
		}
	}
);

/**
 * Badge text for a product, or an empty string. The options come from the Pocket Coffee settings tab; the defaults apply
 * when that plugin is not active.
 *
 * @param WC_Product $product Product.
 * @return string
 */
function pocket_coffee_roast_badge( WC_Product $product ): string {
	$date = $product->get_meta( 'roast_date', true );
	if ( ! $date || 'no' === get_option( 'pocket_coffee_show_badge', 'yes' ) ) {
		return '';
	}

	$label = get_option( 'pocket_coffee_badge_label', __( 'Roasted on', 'pocket-coffee-products' ) );
	$roast = new DateTimeImmutable( $date, wp_timezone() );
	$days  = (int) $roast->diff( new DateTimeImmutable( 'today', wp_timezone() ) )->format( '%r%a' );
	$text  = 'relative' === get_option( 'pocket_coffee_display_mode', 'date' )
		/* translators: %d: days since the roast */
		? sprintf( _n( '%d day since roast', '%d days since roast', $days, 'pocket-coffee-products' ), $days )
		: $label . ' ' . wp_date( get_option( 'date_format' ), $roast->getTimestamp() );

	if ( $days > (int) get_option( 'pocket_coffee_freshness_days', 30 ) ) {
		$text .= ' ' . __( '(past peak)', 'pocket-coffee-products' );
	}
	return esc_html( $text );
}

// Single product page: print the badge after the price (priority 11). Classic themes run this hook from their template;
// block themes get it from WooCommerce's compatibility layer, which fires woocommerce_single_product_summary before the
// excerpt block of the single product template.
add_action(
	'woocommerce_single_product_summary',
	function () {
		global $product;
		$badge = $product instanceof WC_Product ? pocket_coffee_roast_badge( $product ) : '';
		if ( $badge ) {
			echo '<p class="pc-roast-badge">' . $badge . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in pocket_coffee_roast_badge().
		}
	},
	11
);

// A "Brewing notes" tab on the single product page. The filter is the one used by the classic tabs template and by the
// Product Details block.
add_filter(
	'woocommerce_product_tabs',
	function ( array $tabs ): array {
		global $product;
		if ( ! $product instanceof WC_Product || ! $product->get_meta( 'roast_date', true ) ) {
			return $tabs;
		}
		$tabs['pc_brewing'] = array(
			'title'    => __( 'Brewing notes', 'pocket-coffee-products' ),
			'priority' => 25,
			'callback' => function () {
				echo '<p>' . esc_html__( 'Rest the beans for five days after roasting; brew within four weeks.', 'pocket-coffee-products' ) . '</p>';
			},
		);
		return $tabs;
	}
);

The key has no leading underscore, so the value is visible in the Custom Fields box of the classic editor and in the meta_data of the REST API (a key that starts with an underscore is hidden from the Custom Fields box but, as the flight below shows, still listed by REST). The badge hook is woocommerce_single_product_summary at priority 11, right after the price. That is a classic template hook, and it works in the block theme of the lab because WooCommerce injects the classic single-product hooks around the blocks of its single-product template: with the hook and a render_block filter on the Product Price block both attached, the badge was printed twice, which is how the compatibility layer showed itself. The tab at the end of the file uses the filter woocommerce_product_tabs, which the Product Details block honours, and the lab’s product page listed the tabs "Additional information", "Brewing notes" and "Reviews (0)".

A field on every variation

A variation panel has its own hooks. The Pocket Coffee roast batch is a text input in the pricing row, saved with woocommerce_admin_process_variation_object (the object and the position of the row in the posted form) and added to the data the storefront script receives:

<?php
/**
 * A "Roast batch" field on every variation, available to the storefront variation form.
 *
 * @package PocketCoffee\Products
 */

defined( 'ABSPATH' ) || exit;

// Field in the variation panel, below the price fields. $loop is the position of the variation in the form.
add_action(
	'woocommerce_variation_options_pricing',
	function ( int $loop, array $variation_data, WP_Post $variation ) {
		woocommerce_wp_text_input(
			array(
				'id'            => "pc_roast_batch[{$loop}]",
				'name'          => "pc_roast_batch[{$loop}]",
				'label'         => __( 'Roast batch', 'pocket-coffee-products' ),
				'value'         => get_post_meta( $variation->ID, 'pc_roast_batch', true ),
				'wrapper_class' => 'form-row form-row-first',
			)
		);
	},
	10,
	3
);

// Save: this action receives the variation object and the loop index. The nonce is verified by the save_variations AJAX handler.
add_action(
	'woocommerce_admin_process_variation_object',
	function ( WC_Product_Variation $variation, int $i ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing
		if ( isset( $_POST['pc_roast_batch'][ $i ] ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
			$variation->update_meta_data( 'pc_roast_batch', wc_clean( wp_unslash( $_POST['pc_roast_batch'][ $i ] ) ) );
		}
	},
	10,
	2
);

// Make the batch available to the JavaScript of the classic variation form (found_variation event).
add_filter(
	'woocommerce_available_variation',
	function ( array $data, WC_Product $parent_product, WC_Product_Variation $variation ): array {
		$data['pc_roast_batch'] = (string) $variation->get_meta( 'pc_roast_batch', true );
		return $data;
	},
	10,
	3
);

The variations are loaded and saved by AJAX, 15 per page; the nonce and the capability are checked by the handlers before the action fires. The field names carry the position, pc_roast_batch[<loop>], because the posted form contains every variation of the page. A headless Chromium opened the variations of the beans, found the first variation (id 459), and read the field, the gallery box of the next section and the value, then changed the batch and pressed Save changes:

variations on the first page: 15 | pager: 18 variations
variation panel: {"id":"459","batch_field":true,"batch_value":"B-2026-10-A","gallery_field":true,"gallery_count":"3 images","gallery_ids":"353,354,355"}
page errors: []
batch of the first variation: B-2026-10-B

Custom product types

A new product type is a PHP class, a registration, a way to show the right fields and a way to sell it. The Pocket Coffee "Tasting flight" is three 50 g sample bags sold as one item. It behaves like a simple product (one price, one stock, shippable), keeps the origins it contains and is shown as its own type in the editor, the REST API and the shop.

Three columns: the editor registers the type and shows its fields, the save builds the class from the posted type and writes the product_type term, and the storefront, REST API and Store API read the term back and build the class

The class

<?php
/**
 * The "tasting_flight" product type: three 50 g sample bags sold as one purchasable item.
 *
 * @package PocketCoffee\Products
 */

defined( 'ABSPATH' ) || exit;

/**
 * A tasting flight is purchasable like a simple product but keeps its own data: the origins it contains.
 */
class WC_Product_Tasting_Flight extends WC_Product {

	/**
	 * Meta key that stores the origins (term slugs of the pa_origin attribute).
	 */
	const META_ORIGINS = '_pc_flight_origins';

	/**
	 * Product type slug. Stored as the term of the product_type taxonomy.
	 *
	 * @return string
	 */
	public function get_type() {
		return 'tasting_flight';
	}

	/**
	 * A flight can always be added to the cart with quantity 1 from the loop.
	 *
	 * @return string
	 */
	public function add_to_cart_text() {
		return $this->is_purchasable() && $this->is_in_stock()
			? __( 'Add flight to cart', 'pocket-coffee-products' )
			: __( 'Read more', 'pocket-coffee-products' );
	}

	/**
	 * Origins in the flight.
	 *
	 * @return string[] Term slugs.
	 */
	public function get_origins() {
		$origins = $this->get_meta( self::META_ORIGINS, true );
		return is_array( $origins ) ? array_values( $origins ) : array();
	}

	/**
	 * Set the origins in the flight. Persisted by save().
	 *
	 * @param string[] $origins Term slugs.
	 * @return void
	 */
	public function set_origins( array $origins ) {
		$this->update_meta_data( self::META_ORIGINS, array_values( array_map( 'sanitize_title', $origins ) ) );
	}
}

get_type() is the slug stored in the product_type taxonomy. The origins are kept as a meta value through the inherited meta methods (get_meta() and update_meta_data()), which save() persists; a type with many fields of its own would instead declare them in $extra_data and extend the data store, as WC_Product_External does for product_url. WC_Product needs no data store of its own: WC_Data_Store::load( 'product-tasting_flight' ) looks for that key, finds none and falls back to the product store, WC_Product_Data_Store_CPT.

The registration

<?php
/**
 * Registers the "tasting_flight" product type with WooCommerce.
 *
 * @package PocketCoffee\Products
 */

defined( 'ABSPATH' ) || exit;

// 1. Product type drop-down in the classic editor (also the list returned by wc_get_product_types()).
add_filter(
	'product_type_selector',
	function ( array $types ): array {
		$types['tasting_flight'] = __( 'Tasting flight', 'pocket-coffee-products' );
		return $types;
	}
);

// 2. Class used by wc_get_product(): WooCommerce builds the name WC_Product_ plus the type with the first letter upper-cased
// (WC_Product_Tasting_flight). PHP class names are case-insensitive, so the class WC_Product_Tasting_Flight matches and no
// filter is needed. A class with another name needs the woocommerce_product_class filter, but REST writes that send "type"
// do not go through that filter.

// 3. Product data tabs: show Inventory for the new type (the tab only lists the core types).
add_filter(
	'woocommerce_product_data_tabs',
	function ( array $tabs ): array {
		$tabs['inventory']['class'][] = 'show_if_tasting_flight';
		return $tabs;
	}
);

// 4. Storefront: reuse the simple product add-to-cart form (classic templates and Add to Cart + Options).
add_action( 'woocommerce_tasting_flight_add_to_cart', 'woocommerce_simple_add_to_cart' );

// 5. Admin script that shows the price and stock fields for the new type.
add_action(
	'admin_enqueue_scripts',
	function ( string $hook ) {
		global $post_type;
		if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) || 'product' !== $post_type ) {
			return;
		}
		wp_enqueue_script(
			'pocket-coffee-products-admin',
			plugins_url( 'assets/admin.js', POCKET_COFFEE_PRODUCTS_FILE ),
			array( 'jquery', 'wc-admin-product-meta-boxes' ),
			'1.0.0',
			true
		);
	}
);

// 6. Type specific fields: shown only when the type is selected (class show_if_<type>).
add_action(
	'woocommerce_product_options_general_product_data',
	function () {
		global $product_object;

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

		$origins = $product_object instanceof WC_Product_Tasting_Flight ? $product_object->get_origins() : array();
		$terms   = get_terms(
			array(
				'taxonomy'   => 'pa_origin',
				'hide_empty' => false,
			)
		);
		?>
		<p class="form-field _pc_flight_origins_field">
			<label for="_pc_flight_origins"><?php esc_html_e( 'Origins in the flight', 'pocket-coffee-products' ); ?></label>
			<select id="_pc_flight_origins" name="_pc_flight_origins[]" class="wc-enhanced-select" multiple="multiple" style="width: 50%;">
				<?php if ( ! is_wp_error( $terms ) ) : ?>
					<?php foreach ( $terms as $term ) : ?>
						<option value="<?php echo esc_attr( $term->slug ); ?>" <?php selected( in_array( $term->slug, $origins, true ) ); ?>><?php echo esc_html( $term->name ); ?></option>
					<?php endforeach; ?>
				<?php endif; ?>
			</select>
		</p>
		<?php
		echo '</div>';
	}
);

// 7. Save. This action receives the product object before WooCommerce saves it.
add_action(
	'woocommerce_admin_process_product_object',
	function ( WC_Product $product ) {
		if ( ! $product instanceof WC_Product_Tasting_Flight ) {
			return;
		}
		// The nonce and the capability were checked by WooCommerce before this action runs.
		// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized in set_origins().
		$origins = isset( $_POST['_pc_flight_origins'] ) ? (array) wp_unslash( $_POST['_pc_flight_origins'] ) : array();
		$product->set_origins( $origins );
	}
);
/* global jQuery */
jQuery( function ( $ ) {
	// Reuse the core price and stock groups for the new type: add its show_if_ class, then re-run the show/hide logic.
	$( '.options_group.pricing, .inventory_options .options_group, .stock_fields' ).addClass( 'show_if_tasting_flight' );
	$( '.inventory_sold_individually, ._manage_stock_field' ).addClass( 'show_if_tasting_flight' );
	$( 'select#product-type' ).trigger( 'change' );
} );

The main file of the plugin loads these files on woocommerce_loaded, because WC_Product does not exist before that:

<?php
/**
 * Plugin Name:       Pocket Coffee Products
 * Description:       Product types, attributes, custom fields and a custom "Tasting flight" product type for Pocket Coffee.
 * Version:           1.0.0
 * Requires at least: 7.0
 * Requires PHP:      7.4
 * Requires Plugins:  woocommerce
 * Text Domain:       pocket-coffee-products
 * License:           GPL-2.0-or-later
 *
 * @package PocketCoffee\Products
 */

defined( 'ABSPATH' ) || exit;

define( 'POCKET_COFFEE_PRODUCTS_FILE', __FILE__ );

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

// WC_Product exists once WooCommerce has loaded its classes.
add_action(
	'woocommerce_loaded',
	function () {
		require_once __DIR__ . '/includes/class-wc-product-tasting-flight.php';
		require_once __DIR__ . '/includes/custom-product-type.php';
		require_once __DIR__ . '/includes/product-fields.php';
		require_once __DIR__ . '/includes/variation-fields.php';
	}
);

if ( defined( 'WP_CLI' ) && WP_CLI ) {
	require_once __DIR__ . '/includes/class-pc-products-cli.php';
}

Five pieces make the type work:

  1. The drop-down. The filter product_type_selector adds the slug and its label. wc_get_product_types(), the REST schema of the type field and the importer read the same list.

  2. The class lookup. WooCommerce builds the name WC_Product_ plus the slug with its first letter upper-cased (WC_Product_Tasting_flight). PHP class names are not case sensitive, so a class called WC_Product_Tasting_Flight is found without any filter, which also makes REST writes work: POST /wc/v3/products with type tasting_flight builds the class with the same rule. The filter woocommerce_product_class replaces the name for classes that do not follow the convention, but the REST controller does not apply it to the type of a write. The lab proved the difference: the first version of the plugin named the class PC_Product_Tasting_Flight and mapped it with the filter, the editor and wc_get_product() worked, and the REST call that created a product of that type answered 201 with type simple; renaming the class to the convention fixed it.

  3. The show and hide classes. The core price and stock groups only carry show_if_simple, show_if_external and similar classes. The script adds show_if_tasting_flight to them and fires change on the drop-down so that the editor script runs its rules again; the Inventory tab gets the class through the woocommerce_product_data_tabs filter. A group of your own (the origins field) carries the class directly.

  4. The save. WooCommerce builds the class from the posted product-type, calls woocommerce_admin_process_product_object and saves through the data store, which creates the product_type term when it does not exist (the type then shows in wp_term_taxonomy with no setup step).

  5. The sale. The storefront prints the add-to-cart area with do_action( 'woocommerce_' . $type . '_add_to_cart' ); the plugin reuses the simple form (woocommerce_simple_add_to_cart). The cart and the Store API only need is_purchasable() and a price, which the base class provides.

The editor was driven in a headless Chromium: the drop-down listed the new type, each type showed its own fields, and the flight was saved through the real form.

type drop-down: simple=Simple product | grouped=Grouped product | external=External/Affiliate product | variable=Variable product | tasting_flight=Tasting flight
simple          {"price":true,"origins":false,"roast_date":true,"inventory_tab":true,"variations_tab":false,"product_url":false}
variable        {"price":false,"origins":false,"roast_date":true,"inventory_tab":true,"variations_tab":true,"product_url":false}
external        {"price":true,"origins":false,"roast_date":false,"inventory_tab":true,"variations_tab":false,"product_url":true}
grouped         {"price":false,"origins":false,"roast_date":false,"inventory_tab":true,"variations_tab":false,"product_url":false}
tasting_flight  {"price":true,"origins":true,"roast_date":true,"inventory_tab":true,"variations_tab":false,"product_url":false}
inventory tab for the flight: {"sku":true,"manage_stock":true}
saved: post=486&action=edit page errors: []
WC_Product_Tasting_Flight type=tasting_flight status=publish price=12.50 roast_date=2026-10-21 origins=["brazil-cerrado","colombia-huila"]

The true and false values of that output are the contract: the price and the stock fields appeared for the flight, the origins field and the roast date only where they belong, the Variations tab only for the variable type, and the grouped and variable types hide the regular price. The saved product loaded with the custom class and kept its roast date and origins. Through the REST API the type is a first-class value, and the meta of the flight (the underscore key included) is listed in meta_data:

POST /wc/v3/products type=tasting_flight -> 201 id=484 type=tasting_flight price=12.00 meta_data=[["roast_date", "2026-10-20"]]
PUT regular_price 13.00 -> 200 type=tasting_flight price=13.00
GET /wc/v3/products?type=tasting_flight -> 200 total=2 [{"id": 484, "type": "tasting_flight", "sku": "PC-FLIGHT-REST-PRD"}, {"id": 481, "type": "tasting_flight", "sku": "PC-FLIGHT-PRD"}]
meta_data of the flight: [["_pc_flight_origins", ["ethiopia-yirgacheffe", "colombia-huila", "brazil-cerrado"]]]

What breaks when the plugin goes away

A custom type is data that outlives the code. With the plugin deactivated, wc_get_product() could not find the class and fell back to WC_Product_Simple, wc_get_product_types() no longer listed the type, and saving the product wrote the term simple: the type was lost, the origins stayed in the meta. Activating the plugin again did not bring the type back; saving through the custom class did.

$ wp plugin deactivate pocket-coffee-products
WC_Product_Simple type=simple stored term=tasting_flight
product types: ["simple","grouped","external","variable"]
term after a save: simple
$ wp plugin activate pocket-coffee-products
WC_Product_Simple type=simple
after saving through the class: tasting_flight origins=["ethiopia-yirgacheffe","colombia-huila","brazil-cerrado"]

A production extension checks for its own products in its deactivation and uninstall code, or keeps the class available in a must-use plugin (see Extension Development). WooCommerce 11.2.0 hardened the REST controller for the same reason: an update that names an unknown type keeps the stored type instead of converting the product to simple.

Variation galleries

Until 10.9.0 a variation had a single image. Since 10.9.0 (2026-06-23) it can also have an ordered gallery of its own: the changelog entry reads "Add native variation gallery support: each variation can now have its own ordered image set, replacing the retired Additional Variation Images extension", with a migrator for the data of that extension and a fallback that reads its meta key _wc_additional_variation_images until the variation is saved by core. The feature id is variation_gallery; it is enabled and, since 11.1.0, its flag is deprecated (Package::is_enabled() always returns true), so there is nothing to switch on. 11.1.2 and 11.2.0 fixed a recursive request for variation data while rendering the gallery, and 11.2.0 stopped the editor from storing the inherited parent image as the variation’s own image.

In code

WC_Product_Variation uses the same two properties as the parent: image_id (the primary image) and gallery_image_ids (the rest). They are stored in the variation’s _thumbnail_id and _product_image_gallery meta. The variable parent’s get_available_variation() adds image_id, gallery_image_ids and the rendered gallery_images_html to the data of the form. Three PNG files had been imported into the media library beforehand with wp media import; the snippet below sets the first variation’s images and roast batch and reads them back:

<?php
// Native variation galleries (WooCommerce 10.9.0 and later) and the Pocket Coffee custom fields.
$beans_id = wc_get_product_id_by_sku( 'PC-BEANS-PRD' );
$beans    = wc_get_product( $beans_id );
$first    = new WC_Product_Variation( $beans->get_children()[0] );

// Three images that were imported into the media library as pc-prd-bag, pc-prd-beans and pc-prd-cup.
$ids = array();
foreach ( array( 'pc-prd-bag', 'pc-prd-beans', 'pc-prd-cup' ) as $title ) {
	$found = get_posts(
		array(
			'post_type'      => 'attachment',
			'title'          => $title,
			'posts_per_page' => 1,
			'fields'         => 'ids',
		)
	);
	$ids[] = (int) $found[0];
}
list( $bag, $bag_beans, $cup ) = $ids;

echo 'variation ', $first->get_id(), ' (', $first->get_sku(), ') before: image=', $first->get_image_id(), ' gallery=', wp_json_encode( $first->get_gallery_image_ids() ), "\n";
$first->set_image_id( $bag );                           // The primary image of the variation.
$first->set_gallery_image_ids( array( $bag_beans, $cup ) ); // The additional images, in order.
$first->update_meta_data( 'pc_roast_batch', 'B-2026-10-A' );
$first->save();

$first = wc_get_product( $first->get_id() );
echo 'after: image=', $first->get_image_id(), ' gallery=', wp_json_encode( $first->get_gallery_image_ids() ), "\n";
$data = $beans->get_available_variation( $first );
echo 'available variation: image_id=', $data['image_id'], ' gallery_image_ids=', wp_json_encode( $data['gallery_image_ids'] ), ' pc_roast_batch=', $data['pc_roast_batch'], "\n";
echo 'stored: _thumbnail_id=', get_post_meta( $first->get_id(), '_thumbnail_id', true ), ' _product_image_gallery=', get_post_meta( $first->get_id(), '_product_image_gallery', true ), "\n";

$beans->update_meta_data( 'roast_date', '2026-10-20' );
$beans->save();
echo 'roast_date of the beans: ', wc_get_product( $beans_id )->get_meta( 'roast_date' ), "\n";
variation 459 (PC-ETH-WHL-250-PRD) before: image=0 gallery=[]
after: image=353 gallery=[354,355]
available variation: image_id=353 gallery_image_ids=[354,355] pc_roast_batch=B-2026-10-A
stored: _thumbnail_id=353 _product_image_gallery=354,355
roast_date of the beans: 2026-10-20

The REST API exposes the same data as gallery_image_ids on /wc/v3/products/<id>/variations and accepts it on writes (an empty array clears the gallery), and the plain image object carries the primary one:

GET variation 459 -> image_id=353 gallery_image_ids=[354, 355]
PUT variation 460 gallery_image_ids=[355,354] -> 200 [355, 354]
PUT variation 460 gallery_image_ids=[] -> 200 []
GET /wc/v3/products/458 -> type=variable price=8.9 default_attributes=[["Origin", "ethiopia-yirgacheffe"], ["Grind", "Whole bean"], ["Size", "250 g"]]
attributes: [["Origin", "pa_origin", true], ["Grind", "Grind", true], ["Size", "Size", true], ["Roast level", "pa_roast", false]]
variation attributes in REST: [["Origin", "pa_origin", "Ethiopia Yirgacheffe"], ["Grind", "grind", "Whole bean"], ["Size", "size", "250 g"]]

In the editor

Each variation row has a Variation gallery box: a hero image, thumbnails, the count ("3 images"), Manage and Replace buttons and the hint "First image is used as the primary. Drag to reorder." The browser keeps the order in a hidden input named variable_gallery_image_ids[<loop>], a comma-separated list of attachment ids where the first is the primary image; WooCommerce hooks woocommerce_variation_after_upload_image to print the box and woocommerce_admin_process_variation_object to save it (set_image_id() for the first id, set_gallery_image_ids() for the rest). The headless run above read the box of the first variation, "3 images" and 353,354,355, which is the primary image plus the two gallery images the snippet had set, and the save of the batch field kept the images.

On the storefront

The default variation carries three images. The classic gallery of the block template drew the primary image and the thumbnails for it, and the gallery was swapped to the placeholder when the shopper chose a variation without images (the output of the storefront section above shows both states). A plugin that wants a different behaviour filters woocommerce_available_variation or the woocommerce_product_variation_get_gallery_image_ids filter that core’s own legacy fallback uses.

Before 10.9.0 a variation gallery needed the paid Additional Variation Images extension or a plugin of your own with a meta field, a media-frame script and a woocommerce_available_variation filter; that pattern still works, but it duplicates what the core editor now does and should be migrated (the migrator reads the extension’s meta key; custom keys are not touched).

The block product editor was removed

For several years WooCommerce developed a second product editor made of blocks (the "new product editor", "Product Editor v2" or "block product editor"), with its own React package @woocommerce/product-editor, a set of PHP classes under Automattic\WooCommerce\Admin\Features\ProductBlockEditor, product templates and extension APIs for registering blocks and sections. It ran next to the classic editor and never replaced it, and it was retired in three steps:

A timeline from 10.8.0 to 11.2.1: 10.9.0 deprecated the product editor and added native variation galleries, 11.0.0 removed the block editor, 11.1.0 retired its legacy feature flags and 11.2.0 fixed variation gallery storage
Release Change (from the changelog)

10.9.0 (2026-06-23)

"Deprecate Product Editor v2" and removal notices for the extension APIs and the public PHP classes; the product editor template flag removed; a first redesign of the classic Variations tab (a DataViews tab behind the flag product_variations_classic_redesign, off by default).

11.0.0 (2026-08-04)

"Remove the deprecated product block editor feature, package, routes, assets, and extension APIs", plus a compatibility shim for the removed block registry "to avoid third-party extension fatals".

11.1.0 (2026-09-03)

"Keep product editor and pattern toolkit behavior enabled without legacy feature flags": the feature flags of that era no longer need to be checked.

What the lab store showed in 11.2.1: the feature list of FeaturesUtil::get_features() has no product block editor entry; the asset folder of the admin has no product-editor bundle; the list of packages that the dependency extraction plugin externalizes (version 5.1.0) has no @woocommerce/product-editor, and the package is not on the public npm registry either; and the class Automattic\WooCommerce\Admin\Features\ProductBlockEditor\BlockRegistry still exists as a shim. Its methods do nothing (is_registered() and register_block_type_from_metadata() return false) and the first call in a request writes a warning to the WooCommerce log, source product-block-editor:

use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\BlockRegistry;

var_dump( BlockRegistry::get_instance()->register_block_type_from_metadata( '/tmp/my-block' ) );
var_dump( BlockRegistry::get_instance()->is_registered( 'woocommerce/product-name-field' ) );
bool(false)
bool(false)
WARNING Automattic\WooCommerce\Admin\Features\ProductBlockEditor\BlockRegistry is a temporary compatibility shim and will be removed soon. Product editor extension APIs were deprecated in WooCommerce 10.9.0, and the product block editor was removed in WooCommerce 11.0.0 with no replacement.

An extension that registered blocks for the block editor therefore keeps loading and silently does nothing, so its fields are gone from the editor without an error. The migration is a rewrite onto the classic hooks of this page; the correspondence is direct:

Block editor concept Classic equivalent

A product block or a field block in a section

A field printed on a woocommerce_product_options_* hook and saved with woocommerce_admin_process_product_object.

A custom section or tab

A tab added with woocommerce_product_data_tabs and woocommerce_product_data_panels.

A product template (a different form per kind of product)

A custom product type, or show_if_ and hide_if_ classes on the groups.

@woocommerce/product-editor components and the product form store

@woocommerce/components, @woocommerce/data and a React admin page (see Settings and Admin Pages).

The product editor feature checks (Features::is_enabled() and the experimental flags)

Nothing: no check is needed, and the retired admin flags must no longer be read.

Querying products by type

wc_get_products() and WC_Product_Query are the supported way to list products; the arguments (status, type, SKU, category, price, stock, dates, dimensions, paging) are documented in CRUD Objects and Data Stores. These points are specific to types. type accepts one slug or an array and includes custom types; variations are excluded unless you pass type variation, so a parent argument alone returns nothing; sku is a partial match. A custom query variable is added by filtering woocommerce_product_data_store_cpt_get_products_query and reading the variable from the second argument.

<?php
// Product queries by type, SKU and custom field (run with: wp eval-file queries.php).
$ids = wc_get_products(
	array(
		'type'   => 'variable',
		'sku'    => 'PC-BEANS-PRD',
		'return' => 'ids',
	)
);
echo 'variable by SKU: ', wp_json_encode( $ids ), "\n";

$ids = wc_get_products(
	array(
		'type'    => array( 'simple', 'tasting_flight' ),
		'sku'     => '-PRD',
		'return'  => 'ids',
		'orderby' => 'ID',
		'order'   => 'ASC',
	)
);
echo 'simple and tasting_flight: ', wp_json_encode( $ids ), "\n";

$page = wc_get_products(
	array(
		'sku'      => '-PRD',
		'limit'    => 3,
		'paginate' => true,
		'return'   => 'ids',
	)
);
echo 'paginate: total=', $page->total, ' pages=', $page->max_num_pages, ' first page=', wp_json_encode( $page->products ), "\n";

echo 'downloadable: ', wp_json_encode(
	wc_get_products(
		array(
			'downloadable' => true,
			'sku'          => '-PRD',
			'return'       => 'ids',
		)
	)
), "\n";

// Variations are not returned unless the type is asked for.
$beans_id = wc_get_product_id_by_sku( 'PC-BEANS-PRD' );
echo 'parent=', $beans_id, ': ', wp_json_encode(
	wc_get_products(
		array(
			'parent' => $beans_id,
			'limit'  => 4,
			'return' => 'ids',
		)
	)
), "\n";
echo 'type=variation, parent=', $beans_id, ': ', wp_json_encode(
	wc_get_products(
		array(
			'type'    => 'variation',
			'parent'  => $beans_id,
			'limit'   => 4,
			'return'  => 'ids',
			'orderby' => 'ID',
			'order'   => 'ASC',
		)
	)
), "\n";

// A custom query variable: products roasted on or after a date.
add_filter(
	'woocommerce_product_data_store_cpt_get_products_query',
	function ( array $query, array $query_vars ): array {
		if ( ! empty( $query_vars['roast_date_after'] ) ) {
			$query['meta_query'][] = array(
				'key'     => 'roast_date',
				'value'   => sanitize_text_field( $query_vars['roast_date_after'] ),
				'compare' => '>=',
				'type'    => 'DATE',
			);
		}
		return $query;
	},
	10,
	2
);
echo 'roast_date_after 2026-10-20: ', wp_json_encode(
	wc_get_products(
		array(
			'roast_date_after' => '2026-10-20',
			'sku'              => '-PRD',
			'return'           => 'ids',
			'orderby'          => 'ID',
			'order'            => 'ASC',
		)
	)
), "\n";
variable by SKU: [458]
simple and tasting_flight: [477,480,481]
paginate: total=6 pages=2 first page=[481,458,477]
downloadable: [480]
parent=458: []
type=variation, parent=458: [459,460,461,462]
roast_date_after 2026-10-20: [458]

The roast-date filter found the beans and nothing else, because only they carry the meta in this catalogue; the sku argument keeps the other products of the shared test store out of the lists.

Each type through the REST API, the Store API and WP-CLI

The REST API v3 (/wc/v3/products, with consumer key and secret) reads and writes every type, including variations (/wc/v3/products/<id>/variations) and batches; the type property is an enumeration of the slugs of wc_get_product_types(), which is why the custom type is accepted once it is registered and why a made-up one answers 400 with type is not one of simple, grouped, external, variable, and tasting_flight. (observed). The Store API (/wc/store/v1) is public and read-only for products and takes writes for the cart only. Calling add-item for each product of the lab catalogue gave:

Tasting flight                                     201 added: item id 481, quantity 1, line_total 1200
Starter kit                                        201 added: item id 477, quantity 1, line_total 2400
Single-origin beans                                400 woocommerce_rest_missing_attributes
Single-origin beans with the variation attributes  201 added: item id 468, quantity 1, line_total 2990
Guest roaster box                                  400 woocommerce_rest_product_not_purchasable
Brew at home                                       400 woocommerce_rest_product_not_purchasable
Brewing guide                                      201 added: item id 480, quantity 1, line_total 400
GET /wc/store/v1/products?sku=PC-FLIGHT-PRD -> type=tasting_flight is_purchasable=True add_to_cart.text=Add flight to cart prices.price=1200
GET /wc/store/v1/products?sku=PC-BEANS-PRD -> type=variable price_range={"min_amount": "890", "max_amount": "3390"} variations=18 add_to_cart.text=Select options

A simple product, the downloadable product and the custom flight were added; the variable product needed its attribute values (woocommerce_rest_missing_attributes without them, accepted with the variation array that names pa_origin, grind and size); the group and the external product were refused. The Store API product of the flight reported the label from add_to_cart_text() ("Add flight to cart") and the beans reported "Select options".

WP-CLI exposes the same controllers as wp wc product, wp wc product_variation, wp wc product_attribute and their relatives. Ids and results of the lab run:

$ wp wc product create --name="CLI kit" --type=simple --regular_price=24.00 --sku=PC-CLIKIT-PRD --manage_stock=true --stock_quantity=5 --user=<admin-user> --porcelain
485
$ wp wc product get 485 --fields=id,type,sku,price,stock_quantity --format=table --user=<admin-user>
Field	Value
id	485
type	simple
sku	PC-CLIKIT-PRD
price	24.00
stock_quantity	5
$ wp wc product_variation list 458 --per_page=3 --fields=id,sku,price,stock_quantity --format=table --user=<admin-user>
id	sku	price	stock_quantity
476	PC-BRA-FIL-1KG-PRD	29.9	20
475	PC-BRA-FIL-250-PRD	8.9	20
474	PC-BRA-ESP-1KG-PRD	29.9	20

The product CSV exporter and importer (Products > Export and Import) write one row per product and per variation: the columns of an export of the variable products and their variations started with ID, Type, SKU, GTIN, Name, Published, visibility, descriptions, sale dates, tax, stock, dimensions, prices, categories, tags, images, download limits, Parent, Grouped products, Upsells, Cross-sells, External URL, Button text and Position, and continued with four columns per attribute (name, values, visible, global); that export was run before the final catalogue was created.

Older recipes, rechecked

Books and tutorials from 2015, such as the Rauland and Ravensbergen titles, explain products with get_post_meta( $post->ID, '_price' ), $post->ID, direct wp_insert_post() of a product post and classic template overrides. The editor hooks of that period (the meta box actions, woocommerce_process_product_meta, woocommerce_product_tabs, product_type_selector) are still present and were used on this page. What changed is how the data is read and written (CRUD objects instead of post meta), the class lookup of types (the filter and the naming rule instead of a product_type switch), the editor extension model (the block editor came and went) and the block themes, where woocommerce_single_product_summary still fires (through the compatibility layer) but the hooks of the classic loop templates, for example woocommerce_before_shop_loop_item_title, belong to templates that block themes replace with the Product Collection block; see Hooks: Actions and Filters for the recipes of the books rewritten for blocks.

Pitfalls

  • Reading or writing _price, _stock or _sku with get_post_meta() or SQL skips lookup tables, caches and hooks; use the CRUD classes.

  • SKUs are unique across products and variations; save() throws product_invalid_sku.

  • A global attribute slug is limited to 29 bytes and cannot be a WordPress reserved name; product-level attribute keys differ in case between get_attributes() and get_variation_attributes().

  • The attribute lookup table is filled by Action Scheduler; it can be empty right after products were created in bulk, until the queue runs.

  • A grouped or external product is not purchasable, and its Store API add-item fails with woocommerce_rest_product_not_purchasable.

  • A downloadable file outside an approved directory is refused with product_invalid_download, and the refusal leaves a disabled rule.

  • A custom type class that does not follow the WC_Product_<Type> naming rule works in the editor and loses its type in REST writes.

  • Deactivating an extension that owns a product type turns its products into simple products as soon as one is saved.

  • A product type or a field added only with block-editor APIs has nothing to attach to in 11.x.

  • A variation created by code has menu_order 0 and shows newest first in the editor; set set_menu_order() when the order matters.

  • Do not rely on the stored price of a grouped product or on the attribute lookup table to be current in the same request.

What was and was not run

Run on the lab store (WordPress 7.1.3, WooCommerce 11.2.1, PHP 8.3.36, MariaDB 11.8.9, block theme ec-block-child): the catalogue with all six product types through the CRUD classes and a WP-CLI command; the snippets for stock, sales, SKUs, attributes, downloads and approved directories, queries and variation galleries; a completed order that granted a download; the classic editor driven in a headless Chromium (the type drop-down, field visibility per type, a flight saved through the form, the variations panel with the custom field, the gallery box and the AJAX save); the storefront of the beans (default selection, gallery swap, price, badge, tabs); the REST API v3 for types, variations and galleries; the Store API for each type; WP-CLI product and variation commands; the compatibility shim of the removed editor; deactivating the plugin; PHP_CodeSniffer (WooCommerce-Core and WordPress-Extra) on the plugin.

Not run: a product CSV import (only the export columns were read), downloads of the file itself (the permission and the link were read), the xsendfile and redirect download methods, the Add to Cart + Options block with the custom type, colour swatch attributes (the feature is off), the classic Variations tab redesign flag, and any paid product-type extension (subscriptions, bundles, bookings, memberships).