Product Collection and Blocks

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.

This page was written against WordPress 7.1.3 and WooCommerce 11.2.1 (released on 2026-10-09) and checked on 2026-10-27. The Product Collection block and the Product Filters blocks belong to WooCommerce core, so they exist on every self-hosted store; they are block features, so they work in the templates of a block theme and in the pages and posts of a classic theme. They are the way the block Shop, the category pages and the cross-sells of the Cart block list products. The classic shortcodes and widgets that the older books use ([products], the layered navigation widgets) still exist in 11.2.1 and are covered at the end as legacy. Block development in WordPress, including the Interactivity API that the Product Filters block is built on, is covered in Block Development Environment and Interactivity API.

The page goes from what a collection is, to the attributes and the query that a collection produces, to registering your own collection in JavaScript and PHP, and finally to the filter blocks that narrow a collection through the URL. It follows Themes: Block and Classic, which explains the templates that contain these blocks, and it is followed by Cart and Checkout Blocks, the other family of WooCommerce blocks. The running example is the Pocket Coffee shop: a Beans category and an Equipment category, an origin attribute (Ethiopia Yirgacheffe, Colombia Huila, Brazil Cerrado) and a custom collection called Coffee Club picks that shows the cheapest bean bags in stock to club members.

Everything below that runs was run against a local WordPress 7.1.3 and WooCommerce 11.2.1 store: pages were created with WP-CLI and fetched with curl, the registration scripts were executed against the real registry script of WooCommerce in a headless browser page, and the outputs shown are the ones observed. The block editor screens themselves (the collection chooser and the inspector controls) were not driven: opening them needs a signed-in administrator session, and this page documents what the code registers, not how the editor draws it.

Two generations of product blocks

Before the Product Collection block there were two other ways to list products with blocks, and a store that is a few years old usually has pages that use them.

  • Specialised grid blocks (the first generation of WooCommerce blocks): Hand-picked Products, Best Selling Products, Newest Products, On Sale Products, Top Rated Products, Products by Category, Products by Tag, Products by Attribute and All Products. Each one was its own block with its own attributes and its own server-side renderer.

  • Products (Beta), the woocommerce/product-query block: a variation of the core Query Loop block that added product filters to the core block. It was the bridge towards the Product Collection block.

The Product Collection block (woocommerce/product-collection) replaced both. It is one block whose collection decides the starting query, layout and inner blocks, and whose editor controls and server query are shared by every collection. The dates that matter for a migration, from the WooCommerce changelog, are:

Version Change

8.6.0 (2024-02-13)

The __woocommerceNamespace attribute was added to ease the migration from Products (Beta) to Product Collection.

9.1.0 (2024-07-10)

The product archive templates use Product Collection instead of Products (Beta), and Products (Beta) is hidden from the inserter.

9.3.0 (2024-09-10)

The code that upgraded Products (Beta) blocks to Product Collection automatically was removed.

9.7.0 (2025-02-24)

A custom collection can pass extra attributes such as align when it is registered.

10.1.0 (2025-08-12)

The "Products by Category" and "Products by Tag" collections were added.

10.2.0 (2025-09-17)

The carousel layout was added, and the Cart block started to use a Product Collection for cross-sells.

10.3.0 (2025-10-22)

A single-column layout is allowed.

10.4.0 (2025-12-10)

The "Cart Contents" collection was added (used by the block email editor).

10.6.0 (2026-03-10)

The "Products by Brand" collection was added.

11.0.0 (2026-08-04)

Products (Beta) was renamed Products (Deprecated).

In WooCommerce 11.2.1 the older blocks are still registered, so content saved with them keeps rendering, but they are hidden from the inserter. This listing is the output of a short wp eval-file script that reads the block registry:

product-collection        title=Product Collection | inserter='default(true)'
all-products              title=All Products | inserter=false
handpicked-products       title=Hand-picked Products | inserter=false
product-best-sellers      title=Best Selling Products | inserter=false
product-new               title=Newest Products | inserter=false
product-on-sale           title=On Sale Products | inserter=false
product-top-rated         title=Top Rated Products | inserter=false
products-by-attribute     title=Products by Attribute | inserter=false
product-category          title=Products by Category | inserter=false
product-tag               title=Products by Tag | inserter=false
attribute-filter          title=Filter by Attribute Controls | inserter=false
price-filter              title=Filter by Price Controls | inserter=false
stock-filter              title=Filter by Stock Controls | inserter=false
rating-filter             title=Filter by Rating Controls | inserter=false
active-filters            title=Active Filters Controls | inserter=false
filter-wrapper            title=Filter Block | inserter=false
product-filters           title=Product Filters | inserter=true

The script is WP_Block_Type_Registry::get_instance()->get_registered( 'woocommerce/<name>' ) for each name and prints $type->supports['inserter']; the columns for parent, ancestor and deprecations were left out of the listing above. featured-product and featured-category are not in the list because they are still inserted normally; they are single-item showcase blocks, not lists.

Anatomy of the Product Collection block

The Product Collection block with its attributes and context on the container and the product filters, product template, pagination and no results blocks inside

The block is a container. All the products come from the query that the container describes, and every inner block renders one piece of one product, taking the product of the current iteration from block context. The block is dynamic: what is stored in the post content is only the block comments with their JSON attributes and a wrapper; WooCommerce runs the query and renders the markup on every request. This is the saved content of the Cart page of a fresh store, which shows a Product Collection in use as the cross-sells list (the output of wp post get 6 --field=post_content parsed with parse_blocks, reduced to the block names and the attributes of the collection):

woocommerce/product-collection {"queryId":0,"query":{"perPage":3,"pages":1,"offset":0,"postType":"product","order":"asc",
  "orderBy":"title","search":"","exclude":[],"inherit":false,"taxQuery":[],"isProductCollectionBlock":true,"featured":false,
  "woocommerceOnSale":false,"woocommerceStockStatus":["instock","outofstock","onbackorder"],"woocommerceAttributes":[],
  "woocommerceHandPickedProducts":[],"filterable":false,"relatedBy":{"categories":true,"tags":true}},"tagName":"div",
  "displayLayout":{"type":"flex","columns":3,"shrinkColumns":true},"dimensions":{"widthType":"fill"},
  "collection":"woocommerce/product-collection/cross-sells","hideControls":["filterable"],"queryContextIncludes":["collection"]}
  core/heading
  woocommerce/product-template
    woocommerce/product-image {"showSaleBadge":false,"imageSizing":"thumbnail","isDescendentOfQueryLoop":true}
      woocommerce/product-sale-badge {"align":"right"}
    core/post-title {"isLink":true, ... ,"__woocommerceNamespace":"woocommerce/product-collection/product-title"}
    woocommerce/product-price {"isDescendentOfQueryLoop":true,"textAlign":"center","fontSize":"small"}
    woocommerce/product-button {"isDescendentOfQueryLoop":true,"textAlign":"center","fontSize":"small"}

The post title inside the loop is the core core/post-title block with the namespace attribute __woocommerceNamespace; WooCommerce uses the namespace to recognise it and to add the Interactivity API directive that raises the wc-blocks_viewed_product event described at the end of the page.

Block attributes

These are the attributes of the block in 11.2.1, read from its block.json:

Attribute Type Meaning

queryId

number

Identifier of the loop on the page. Pagination, random ordering and the filter blocks use it to tell two collections apart.

query

object

The query description; every key is in the next table.

tagName

string

The HTML element of the wrapper, div by default.

displayLayout

object

type (flex, list or carousel), columns (number) and shrinkColumns (boolean).

dimensions

object

widthType (fill or fixed) and fixedWidth.

collection

string

The name of the collection the block started from, for example woocommerce/product-collection/on-sale. Empty for a collection that was built by hand.

hideControls

string[]

Which filter controls the inspector must not show; see below.

queryContextIncludes

string[]

Which extra context values the query uses; the core collections write ["collection"].

forcePageReload

boolean

When true, pagination and filters reload the page instead of using the client-side router. Default false.

convertedFromProducts

boolean

Marks a block that was converted from Products (Beta).

The official registration page lists grid and stack as the values of displayLayout.type, which disagrees with the code. They are the names of the options in the editor source (LayoutOptions.GRID and LayoutOptions.STACK), but the stored values are flex and list: the enum in block.json is flex, list, carousel, and the saved content shown above says "type":"flex". Use the stored values in markup and in registrations; the registration function does not check the value, so a wrong one is accepted silently and renders as the default layout.

The query object

The query attribute is a plain object. These are the keys that the registration function validates, with the type each one must have, plus the keys that WooCommerce itself writes into saved content:

Key Type Meaning

perPage

number

Products per page.

pages

number

Maximum number of pages (saved as 0 in the markup of the shop template).

offset

number

Products skipped at the start.

order

asc or desc

Direction.

orderBy

string

A WordPress orderby value (date, title, menu_order…​) or one of the WooCommerce values popularity, rating, price, sales, post__in, menu_order, random. These seven are the custom_order_opts of the query builder.

search

string

Keyword.

taxQuery

object

Taxonomy term IDs by taxonomy, for example {"product_cat":[19]}.

featured

boolean

Only featured products.

timeFrame

object

operator (in or not-in) and value, a date string that PHP strtotime() understands.

woocommerceOnSale

boolean

Only products on sale.

woocommerceStockStatus

string[]

Allowed stock statuses: instock, outofstock, onbackorder.

woocommerceAttributes

object[]

Attribute terms, for example {"termId":23,"taxonomy":"pa_color"}.

woocommerceHandPickedProducts

number[]

Product IDs.

priceRange

object

min and max.

inherit

boolean

Use the query of the template (the main query) instead of this description.

filterable

boolean

Let the Product Filters blocks and the URL parameters narrow this query.

isProductCollectionBlock

boolean

Always true; it is how the server recognises the block inside the generic query loop hooks.

relatedBy

object

categories and tags, used by the Related Products collection.

postType, exclude, author, sticky

Core Query Loop keys that the block keeps for compatibility.

inherit and filterable are the two switches that decide where the products come from. With inherit set, the block uses the products of the page it is on: the Shop page, a category, a tag, an attribute archive or a search result. The block Product Catalog template of 11.2.1 does exactly that (its Product Collection has "inherit":true). With filterable set on a query that is not inherited, the block still describes its own products but also honours the filter parameters of the URL. With neither, a URL parameter is ignored, which is what the Cart cross-sells want.

Hiding controls

hideControls takes the names of inspector filters that a collection does not want the merchant to change, because changing them would make the collection something else (a "New Arrivals" collection that the merchant re-orders by price is no longer New Arrivals). The names are order, attributes, created, featured, hand-picked, keyword, on-sale, stock-status, taxonomy and price-range; the registry also writes inherit, which the collections never offer, and the Cart cross-sells write filterable. The list hides controls in the editor only: the saved query still applies, and a merchant who edits the markup can change anything.

The core collections

A collection is a block variation of woocommerce/product-collection: a name, a title, a set of default attributes and a set of inner blocks. The editor shows the collections in a chooser when a Product Collection is inserted and in the inserter. These are the fourteen collection names that the 11.2.1 editor bundle registers:

Name (after woocommerce/product-collection/) What it selects Notes

product-catalog

All products in the catalog; its description says the results can match the current template, page or search term.

Registered with an empty scope, so it is not offered in the chooser or the inserter.

featured

Products marked as featured.

Hides the Featured control.

best-sellers

Ordered by popularity (sales), descending, five items.

Hides the order control.

new-arrivals

Ordered by date, newest first, five items.

Hides the order control.

on-sale

Products on sale.

Hides the On Sale control.

top-rated

Ordered by rating, descending.

Hides the order control.

hand-picked

Product IDs chosen in the inspector; shows nothing until some are chosen.

Hides the Hand-picked and order controls.

by-category

Products of the chosen categories; shows nothing until one is chosen.

Added in 10.1.0.

by-tag

Products of the chosen tags; shows nothing until one is chosen.

Added in 10.1.0.

by-brand

Products of the chosen brands; shows nothing until one is chosen.

Added in 10.6.0.

related

Products related to the product of the page (or a chosen one), by category and tag.

Needs a product reference.

upsells

The upsell products of a product, of the cart or of an order.

Needs a product, cart or order reference.

cross-sells

The cross-sell products of a product, of the cart or of an order.

Used by the Cart block since 10.2.0.

cart-contents

The products in the cart; in the editor it shows recent products as a sample.

Added in 10.4.0; used in emails.

The shape of a core collection is easy to read in the editor source: on-sale is displayLayout flex with five columns, query with woocommerceOnSale: true, perPage: 5, pages: 1, hideControls with the On Sale control, and the inner blocks are a centred heading followed by the product template. A custom collection is built the same way.

The "needs a reference" collections (related, upsells, cross-sells) take the product from the location of the block: on a single product template the product of the page, in the cart the products of the cart, in an order email the products of the order. The editor passes the location in the preview request, and the server reads productCollectionLocation from block context on the front end. A collection that declares usesReference shows a "Preview" label in the editor when the reference only exists on the front end.

How the query is built

The front end path through query_loop_block_query_vars and the editor preview path through rest_product_query, each with WooCommerce at priority 10 and a custom callback at priority 20

The block does not run its own database query code on the front end; it plugs into WordPress. When the product template renders, the core loop machinery calls the query_loop_block_query_vars filter with the default query vars, the block instance and the page number. The Product Collection controller registers a callback on that filter, ignores everything that is not one of its own loops ($block->context['query']['isProductCollectionBlock'] must be true), and returns the query vars that its QueryBuilder computed:

  • the common values: posts_per_page, order, offset (computed from the page number), post__in, post_status publish, post_type product, paged and s;

  • the filters: on sale, stock status, attributes, price range, featured, time frame, hand-picked products and the taxonomy query;

  • the ordering, including the custom orderings that need extra SQL (price, sales, menu_order with a title fallback and a seeded random);

  • the collection handler, if the collection has one: a PHP callback registered under the collection name that can replace the query (the Related, Upsells, Cross-sells and Cart Contents collections use it, and so do the "choose something first" collections, which return post__in => [ -1 ] until a term or a product is chosen);

  • the filter clauses from the URL parameters, only if the block is inherit or filterable.

In the editor the same rules are applied to a different request. The editor lists products through the REST route GET /wp/v2/product with extra parameters (isProductCollectionBlock, productCollectionQueryContext with the collection name, productCollectionLocation, previewState and the filter values), and the controller hooks rest_product_query to apply the same query builder. A custom rule therefore has to be written twice, once for each filter, or the preview and the page disagree.

The collection handler registry is internal in 11.2.1: HandlerRegistry::register_collection_handlers() throws if a name is registered twice, and neither that class nor the controller offers a filter or an action that lets a plugin add a handler. A third-party collection therefore combines two things: JavaScript that registers the variation (name, defaults, inner blocks), and the two WordPress filters above, which receive the collection name and may edit the query. The next section builds exactly that.

Registering a collection

__experimentalRegisterProductCollection() is a function of the @woocommerce/blocks-registry package. It validates its argument, builds a block variation of woocommerce/product-collection and registers it with the WordPress block variations API. The name has experimental in it and the documentation says it may change; in 11.2.1 it is still the only documented way to add a collection. It can be reached in two ways:

  • with a build that uses @woocommerce/dependency-extraction-webpack-plugin, import it: import { __experimentalRegisterProductCollection } from '@woocommerce/blocks-registry'; the plugin turns the import into a read of window.wc.wcBlocksRegistry and adds the wc-blocks-registry script handle to the dependencies of the generated index.asset.php. Do not install @woocommerce/blocks-registry from npm for this;

  • without a build, read the global: window.wc.wcBlocksRegistry.__experimentalRegisterProductCollection, and list wc-blocks-registry as a dependency of your script. The example below does this, so it runs without a toolchain.

Either way the script must be loaded in the block editor, not on the front end: enqueue it on enqueue_block_editor_assets.

Fields of a collection

Field Type Meaning

name

string, required

Unique, machine-readable name. The pattern is plugin-name/product-collection/collection-name, alphanumerics and hyphens only. A name that does not match the pattern is registered anyway with a console warning; an empty or missing name is an error and nothing is registered.

title

string, required

Shown in the chooser and the inserter. Without it the call logs "Invalid title" and registers nothing.

description

string

Help text under the title.

icon

string or element

A Dashicons name or a React element.

keywords

string[]

Extra search terms in the inserter.

category

string

Inserter category.

attributes

object

Defaults for the block: query, displayLayout, dimensions, hideControls, queryContextIncludes, and since 9.7.0 core attributes such as align.

innerBlocks

array

Block template of the inner blocks. If omitted, the registry supplies a default template (three blocks in 11.2.1).

scope

string[]

block (collection chooser and toolbar button), inserter, transform. Default ["block","inserter"]. [] hides the collection from both; ["inserter"] shows it only in the inserter.

usesReference

string[]

product, archive, cart or order: the collection needs that context, and the editor shows a Preview label when it is missing in the editor but available on the front end.

preview

object

initialPreviewState (isPreview, previewMessage) and an optional setPreviewState( { setState, attributes, location } ) function that may return a cleanup function.

isDefault, isActive

Managed by WooCommerce; do not pass them. isActive compares the block’s collection attribute with the name.

A complete example: Coffee Club picks

The plugin has one PHP file and one script. The PHP file enqueues the script in the editor, and applies the rule that WordPress cannot express in attributes: "the category that holds the beans", which is a term ID on every site but a slug that the shop owner controls. The category slug goes through a filter so that a store with another slug can change it.

<?php
/**
 * Plugin Name:       Pocket Coffee Collections
 * Description:       Registers the "Coffee Club picks" collection for the Product Collection block.
 * Version:           0.1.0
 * Requires at least: 7.0
 * Requires PHP:      7.4
 * Requires Plugins:  woocommerce
 * License:           GPL-2.0-or-later
 * Text Domain:       pocket-coffee-collections
 *
 * @package PocketCoffee
 */

defined( 'ABSPATH' ) || exit;

const POCKET_COFFEE_CLUB_COLLECTION = 'pocket-coffee/product-collection/club-picks';

// 1. The editor script registers the collection (it only runs in the block editor).
add_action(
	'enqueue_block_editor_assets',
	function () {
		wp_enqueue_script(
			'pocket-coffee-collections',
			plugins_url( 'assets/collections.js', __FILE__ ),
			array( 'wc-blocks-registry', 'wp-i18n' ),
			filemtime( plugin_dir_path( __FILE__ ) . 'assets/collections.js' ),
			true
		);
	}
);

/**
 * The category that holds the coffee beans (slug), filterable for other shops.
 *
 * @return string
 */
function pocket_coffee_club_category_slug() {
	/**
	 * Filters the slug of the product category that the Coffee Club picks collection reads.
	 *
	 * @since 0.1.0
	 *
	 * @param string $slug Category slug, 'beans' by default.
	 */
	return (string) apply_filters( 'pocket_coffee_club_category_slug', 'beans' );
}

// 2. Front end: WooCommerce builds the query first (priority 10); a later callback adjusts it.
add_filter(
	'query_loop_block_query_vars',
	function ( $query, $block ) {
		if ( POCKET_COFFEE_CLUB_COLLECTION !== ( $block->context['collection'] ?? '' ) ) {
			return $query;
		}
		// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
		$query['tax_query'][] = array(
			'taxonomy' => 'product_cat',
			'field'    => 'slug',
			'terms'    => array( pocket_coffee_club_category_slug() ),
		);
		return $query;
	},
	20,
	2
);

// 3. Editor preview: the editor fetches products through the REST API, so repeat the rule for /wp/v2/product.
add_filter(
	'rest_product_query',
	function ( $args, $request ) {
		$context = $request->get_param( 'productCollectionQueryContext' );
		if ( POCKET_COFFEE_CLUB_COLLECTION !== ( $context['collection'] ?? '' ) ) {
			return $args;
		}
		// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
		$args['tax_query'][] = array(
			'taxonomy' => 'product_cat',
			'field'    => 'slug',
			'terms'    => array( pocket_coffee_club_category_slug() ),
		);
		return $args;
	},
	20,
	2
);

The script registers the variation. It reads the registry from the global, so it needs no build step:

( function () {
	'use strict';

	const { __ } = window.wp.i18n;
	const { __experimentalRegisterProductCollection: registerProductCollection } = window.wc.wcBlocksRegistry;

	registerProductCollection( {
		name: 'pocket-coffee/product-collection/club-picks',
		title: __( 'Coffee Club picks', 'pocket-coffee-collections' ),
		description: __( 'The four cheapest bean bags in stock, for Coffee Club members.', 'pocket-coffee-collections' ),
		icon: 'coffee',
		keywords: [ 'coffee', 'club', 'beans' ],
		attributes: {
			query: {
				perPage: 4,
				orderBy: 'price',
				order: 'asc',
				woocommerceStockStatus: [ 'instock' ],
			},
			displayLayout: { type: 'flex', columns: 4, shrinkColumns: true },
			hideControls: [ 'created', 'featured', 'hand-picked' ],
		},
		innerBlocks: [
			[ 'core/heading', { level: 2, content: __( 'Coffee Club picks', 'pocket-coffee-collections' ) } ],
			[
				'woocommerce/product-template',
				{},
				[
					[ 'woocommerce/product-image' ],
					[ 'core/post-title', { level: 3, isLink: true, __woocommerceNamespace: 'woocommerce/product-collection/product-title' } ],
					[ 'woocommerce/product-price', { textAlign: 'center' } ],
				],
			],
			[ 'woocommerce/product-collection-no-results' ],
		],
	} );
}() );

node --check accepts the script and php -l accepts the plugin file. The block editor needs a signed-in administrator to load, so the registration was exercised in a different way: a throw-away plugin enqueued wp-blocks, wc-blocks-registry and this script on a public page, and the page was opened in a headless browser. Reading the variations that the real registry function produced gives (trimmed):

> wp.blocks.getBlockVariations('woocommerce/product-collection').filter( v => v.name.startsWith('pocket-coffee') )
name:       pocket-coffee/product-collection/club-picks
title:      Coffee Club picks
isDefault:  false
attributes: { query: { perPage: 4, pages: 0, offset: 0, postType: "product", order: "asc", orderBy: "price", search: "",
              exclude: [], inherit: false, taxQuery: {}, isProductCollectionBlock: true, featured: false,
              woocommerceOnSale: false, woocommerceStockStatus: ["instock"], woocommerceAttributes: [],
              woocommerceHandPickedProducts: [], filterable: false, relatedBy: { categories: true, tags: true } },
              tagName: "div", displayLayout: { type: "flex", columns: 4, shrinkColumns: true },
              dimensions: { widthType: "fill" }, queryContextIncludes: ["collection"], forcePageReload: false,
              hideControls: ["inherit","created","featured","hand-picked"],
              collection: "pocket-coffee/product-collection/club-picks", inherit: false }

Four observations follow from that output. The registry fills in every key of query that the collection did not set with the defaults of the block (so a registration only needs to list what is different). It adds inherit to hideControls and sets collection to the name, which is what lets the server recognise the collection. It keeps scope unset when the collection did not pass one, and it never stores usesReference on the variation object, so that field is consumed elsewhere in the editor; the "Preview" label it drives was not exercised.

The same collection on the page

A page with the markup that the collection produces (the editor writes the same thing when a merchant picks the collection) is created with WP-CLI. The page here is created from a file because the block markup contains quotes:

<!-- wp:woocommerce/product-collection {"queryId":1,"query":{"perPage":4,"pages":0,"offset":0,"postType":"product","order":"asc","orderBy":"price","search":"","exclude":[],"inherit":false,"taxQuery":{},"isProductCollectionBlock":true,"featured":false,"woocommerceOnSale":false,"woocommerceStockStatus":["instock"],"woocommerceAttributes":[],"woocommerceHandPickedProducts":[],"filterable":false,"relatedBy":{"categories":true,"tags":true}},"tagName":"div","displayLayout":{"type":"flex","columns":4,"shrinkColumns":true},"collection":"pocket-coffee/product-collection/club-picks","hideControls":["created","featured","hand-picked"],"queryContextIncludes":["collection"]} -->
<div class="wp-block-woocommerce-product-collection"><!-- wp:woocommerce/product-template -->
<!-- wp:post-title {"level":3,"isLink":true,"__woocommerceNamespace":"woocommerce/product-collection/product-title"} /-->
<!-- wp:woocommerce/product-price {"isDescendentOfQueryLoop":true} /-->
<!-- /wp:woocommerce/product-template --></div>
<!-- /wp:woocommerce/product-collection -->
wp post create ./club-picks.html --post_type=page --post_status=publish \
  --post_title='Coffee Club picks' --post_name=club-picks --porcelain

On the test store the categories are coffee-beans (four bean bags, one of them out of stock) and coffee-equipment, so a small configuration plugin returned coffee-beans from the pocket_coffee_club_category_slug filter. Fetching the page with the plugin active, then with the plugin deactivated (the same saved markup, no server rule), then the REST route that the editor uses with the collection name in the query string, gave (the product names were taken from the wp-block-post-title links; the first two commands strip a (hello) suffix that a product-name filter active on the test store appends, which is not part of this example):

# plugin active
curl -s http://localhost:8081/club-picks/ | grep -oE 'wp-block-post-title[^>]*><a[^>]*>[^<]+' | sed 's/.*>//;s/ (hello)//'
Colombia Huila 250 g
Brazil Cerrado 250 g
Ethiopia Yirgacheffe 250 g

# plugin deactivated: same page, the rule is gone and every in-stock product of the store is eligible (first four shown)
Brewing guide (PDF)
Colombia Huila 250 g
Brazil Cerrado 250 g
Single-origin beans

# the editor's preview request (the editor also sends the stock status of the query)
curl -s -g 'http://localhost:8081/wp-json/wp/v2/product?per_page=10&isProductCollectionBlock=true&productCollectionQueryContext[collection]=pocket-coffee/product-collection/club-picks&woocommerceStockStatus[]=instock&orderby=price&order=asc&_fields=id'
[{"id":77},{"id":78},{"id":76}]

The ordering is by price, ascending: 8.90, 8.90 and 9.90. The fourth bag of the category, the 1 kg Brazil Cerrado at its sale price of 24.90, is out of stock on the test store, so the stock status of the collection leaves it out. Without the collection name in the REST request the same route returns unrelated products, which is the reason the editor passes the name.

The examples of the official page, run

The registration documentation has six examples (a plain collection, a collection with a preview state, one with inner blocks, usesReference, and two scope variants). They were registered, together with deliberately wrong ones, in the same headless page. The console output of the real registry function was:

warning: To prevent conflicts with other collections, please use a unique name following the pattern: "<plugin-name>/product-collection/<collection-name>". Ensure "<plugin-name>" is your plugin name and "<collection-name>" is your collection name. Both should consist only of alphanumeric characters and hyphens (e.g., "my-plugin/product-collection/my-collection").
error: Invalid title: title must be a non-empty string.
error: Collection could not be registered due to invalid configuration.

The warning came from the name badname (registered anyway) and the two errors from a registration with a name and no title (not registered). The scope: [] collection and the scope: ["inserter"] collection were both registered with that scope stored on the variation, as documented. The documentation example that sets displayLayout.type to grid was accepted but the stored value that the block understands is flex.

Product Filters

Product Filters blocks write URL parameters, the Interactivity API store navigates, the server builds the query clauses and the filter counts, and plugins can replace the filter data

The Product Filters block (woocommerce/product-filters) is the filtering system for a product list. It is a container whose children are the individual filters; every filter is itself a small container whose children are the display style (a list of checkboxes, or chips). Like the Product Collection it is dynamic, so only block comments are stored.

Block What it filters URL parameters

woocommerce/product-filter-active

Shows the active filters as removable chips and a clear button. Children: product-filter-removable-chips, product-filter-clear-button.

none

woocommerce/product-filter-price

Price range, with an optional product-filter-price-slider child.

min_price, max_price

woocommerce/product-filter-rating

Average rating.

rating_filter

woocommerce/product-filter-status

Stock status (the Availability filter).

filter_stock_status

woocommerce/product-filter-attribute

One global attribute (attributeId), with showCounts, queryType (or or and), displayStyle, selectType, sortOrder (default count-desc) and hideEmpty.

filter_<attribute slug>, query_type_<attribute slug>

woocommerce/product-filter-taxonomy

A product taxonomy (taxonomy, default product_cat).

categories, tags, brands, or filter_<taxonomy> for a custom taxonomy

woocommerce/product-filter-checkbox-list, product-filter-chips

The display styles.

none

The default template that the editor inserts for a new Product Filters block is: a level-2 heading "Filters", Active Filters, Price, Rating, Attribute, Taxonomy and Status. The block itself supports the colour, typography and spacing settings, a sticky position (since 11.2.0), and an overlay mode (overlayMode: off, mobile or always, added in 11.2.0, plus overlayPosition: left or right) that shows the filters in a drawer opened by a "Filter products" button. Since 10.9.0 a page or template can hold only one Product Filters block.

A filter block must know which collection it narrows. A Product Filters block inside a Product Collection receives the collection’s query and queryId through block context (its usesContext is postId, query, queryId and forcePageReload). When the collection inherits the template’s query, the block can also be placed beside the collection (for example in a sidebar column), because both read the same URL.

Markup and a run

This is a Product Collection with Product Filters inside (price slider, the origin attribute with counts, and availability). attributeId is the ID of the global attribute created from the attributes screen (1 on the test store); queryType is left at its default or:

<!-- wp:woocommerce/product-collection {"queryId":0,"query":{"perPage":6,"pages":0,"offset":0,"postType":"product","order":"asc","orderBy":"title","search":"","exclude":[],"inherit":false,"taxQuery":{"product_cat":[19]},"isProductCollectionBlock":true,"featured":false,"woocommerceOnSale":false,"woocommerceStockStatus":["instock","outofstock","onbackorder"],"woocommerceAttributes":[],"woocommerceHandPickedProducts":[],"filterable":true,"relatedBy":{"categories":true,"tags":true}},"tagName":"div","displayLayout":{"type":"flex","columns":3,"shrinkColumns":true},"dimensions":{"widthType":"fill"},"queryContextIncludes":["collection"]} -->
<div class="wp-block-woocommerce-product-collection"><!-- wp:woocommerce/product-filters -->
<div class="wp-block-woocommerce-product-filters"><!-- wp:woocommerce/product-filter-price -->
<div class="wp-block-woocommerce-product-filter-price"><!-- wp:woocommerce/product-filter-price-slider /--></div>
<!-- /wp:woocommerce/product-filter-price -->

<!-- wp:woocommerce/product-filter-attribute {"attributeId":1,"showCounts":true} -->
<div class="wp-block-woocommerce-product-filter-attribute"><!-- wp:woocommerce/product-filter-checkbox-list /--></div>
<!-- /wp:woocommerce/product-filter-attribute -->

<!-- wp:woocommerce/product-filter-status {"showCounts":true} -->
<div class="wp-block-woocommerce-product-filter-status"><!-- wp:woocommerce/product-filter-checkbox-list /--></div>
<!-- /wp:woocommerce/product-filter-status --></div>
<!-- /wp:woocommerce/product-filters -->

<!-- wp:woocommerce/product-template -->
<!-- wp:post-title {"level":3,"isLink":true,"__woocommerceNamespace":"woocommerce/product-collection/product-title"} /-->
<!-- wp:woocommerce/product-price {"isDescendentOfQueryLoop":true} /-->
<!-- /wp:woocommerce/product-template -->

<!-- wp:woocommerce/product-collection-no-results -->
<!-- wp:paragraph --><p>No coffee matches these filters.</p><!-- /wp:paragraph -->
<!-- /wp:woocommerce/product-collection-no-results --></div>
<!-- /wp:woocommerce/product-collection -->

The collection is restricted to the Beans category ("taxQuery":{"product_cat":[19]}, term ID 19 on the test store, where the two categories are named coffee-beans and coffee-equipment) and the page was requested with different URLs. The parameters are the ones in the table above; with filterable true the server applies them, and the output is the list of product names in the page:

for u in "" "?min_price=9&max_price=10" "?filter_origin=brazil-cerrado" \
         "?filter_origin=brazil-cerrado,colombia-huila&query_type_origin=or" \
         "?filter_origin=brazil-cerrado&filter_stock_status=instock" "?categories=coffee-equipment"; do
  echo "== $u"
  curl -s "http://localhost:8081/product-filters-demo/$u" | grep -oE 'wp-block-post-title[^>]*><a[^>]*>[^<]+' | sed 's/.*>//;s/ (hello)//'
done
==
Brazil Cerrado 1 kg
Brazil Cerrado 250 g
Colombia Huila 250 g
Ethiopia Yirgacheffe 250 g
== ?min_price=9&max_price=10
Ethiopia Yirgacheffe 250 g
== ?filter_origin=brazil-cerrado
Brazil Cerrado 1 kg
Brazil Cerrado 250 g
== ?filter_origin=brazil-cerrado,colombia-huila&query_type_origin=or
Brazil Cerrado 1 kg
Brazil Cerrado 250 g
Colombia Huila 250 g
== ?filter_origin=brazil-cerrado&filter_stock_status=instock
Brazil Cerrado 250 g
== ?categories=coffee-equipment

The attribute parameter carries the term slug (brazil-cerrado), not its ID or its name: filter_origin=brazil matches nothing. The last request returns nothing because the collection is already restricted to Beans and the filter asks for Equipment: the filter narrows the collection with AND, it never widens it. The filter_origin and query_type_origin names exist because the attribute is called origin (pa_origin); WooCommerce reads the list of global attributes and the list of public product taxonomies once per request to know which parameter names are valid (Automattic\WooCommerce\Internal\ProductFilters\Params, an internal class).

On a template that inherits the query the same parameters work without any filter block on the page, because they are applied to the main query. On the Shop page of the test store (inherit: true) these requests gave: /shop/?min_price=30 only products whose price is 30 or more; /shop/?categories=coffee-equipment only the two Equipment products; /shop/?filter_origin=colombia-huila only products with that origin.

Client-side navigation

The Product Filters block is built on the Interactivity API. Its interactive store is woocommerce/product-filters; when the shopper ticks a checkbox the store computes the new query string and calls actions.navigate(), which asks the router for the new URL and swaps the router region of the page, so the product list changes without a page load. A run in the headless browser ticked the first origin checkbox on the page above: the URL became ?filter_origin=brazil-cerrado&query_type_origin=or and the list became the two Brazil bags. A variable that the test had set on window beforehand was still there afterwards, which shows that the router swapped the list and the page was not reloaded.

Client-side navigation can be switched off per collection with forcePageReload (the "Force page reload" setting of the block). The Product Collection then has no router region, so the filter block falls back to a full page load, which is what a collection needs when it contains a block that is not compatible with the Interactivity router. WooCommerce also disables client-side navigation automatically when it finds a block inside a collection that does not declare supports.interactivity (or supports.interactivity.clientNavigation). On a classic (non-block) theme the product archives have client-side navigation disabled globally, because classic archive templates cannot be swapped by the router.

Counts, caching and hooks

The numbers shown next to the options and the minimum and maximum of the price slider come from FilterData, which queries the product lookup tables for the current query (the collection’s restrictions plus the other active filters, but not the filter being drawn) and caches the answer in transients. Three filters let a plugin change that:

Hook Purpose

woocommerce_pre_product_filter_data

Return an array to skip the database and the cache: for a catalogue that lives in an external search service. Arguments: null, the filter type (price, stock, rating, attribute, taxonomy), the query vars, extra arguments. Since 9.9.0.

woocommerce_product_filter_data

Adjust the result that was computed. Same arguments. Since 9.9.0.

woocommerce_product_filter_cache_max_entries

Maximum number of cached combinations, default 1000; 0 disables the cap. Since 10.8.0.

woocommerce_blocks_product_filters_selected_items

Add items to the active-filters list: an array of items (type, value, activeLabel) and the URL parameters. Since 9.7.0.

The first one was tried: this filter makes every price slider report a range of 0 to 100,

add_filter(
	'woocommerce_pre_product_filter_data',
	function ( $results, $filter_type ) {
		if ( 'price' === $filter_type ) {
			return array(
				'min_price' => 0,
				'max_price' => 100,
			);
		}
		return $results;
	},
	10,
	2
);

and the page then contained "minPrice":0, "maxPrice":100 and maxRange, minRange of 100 and 0 in the slider context, where before it had 8 and 25 (the prices of the four bags rounded outwards). The woocommerce_product_filter_data filter, used to round the computed prices, gave no visible difference on this data because the slider already rounds to its step.

Legacy: shortcodes, widgets and older filter blocks

The books on WooCommerce 2.x and 3.x list products with shortcodes and filter them with widgets in a sidebar. That approach is still present in 11.2.1: the shortcodes [products], [product_category], [product_categories], [recent_products], [sale_products], [best_selling_products], [top_rated_products], [featured_products], [product_attribute] and [related_products] are registered, and so are the widgets Layered Nav, Layered Nav Filters, Price Filter, Rating Filter, Products, Top Rated Products and Product Categories. On the test store

[products category="coffee-beans" limit="3" orderby="price" order="asc" columns="3"]

rendered an unordered list with the class products columns-3 containing Colombia Huila 250 g, Brazil Cerrado 250 g and Ethiopia Yirgacheffe 250 g. This is a page of classic markup (ul.products, li.product), so it is styled by the theme’s WooCommerce CSS and not by the block styles, and it cannot be edited with the collection controls. The mapping to the current tools is:

Legacy Current equivalent

[products], [recent_products], [best_selling_products]

A Product Collection with the New Arrivals or Best Sellers collection.

[sale_products]

The On Sale collection.

[featured_products]

The Featured collection.

[product_category], [product_attribute]

Products by Category, or Product Collection with a taxQuery or woocommerceAttributes.

Layered Nav, Price Filter and Rating Filter widgets

The Product Filters block.

The older filter blocks (Filter by Price and the other woocommerce/*-filter blocks)

The Product Filters block. They are hidden from the inserter in 11.2.1 but still render in saved content; the Product Collection controller still has code to support them, so existing pages keep working.

A migration is a manual job here: this page did not exercise any editor conversion tool, and the code that converted Products (Beta) blocks to Product Collection was removed in 9.3.0. Keep the old page until the new one shows the same products, and compare the two lists (the same curl and grep shown above work for that).

DOM events and styling

The Product Collection block raises two DOM events on document. This listener was installed before the page scripts ran (through the browser’s "evaluate on new document" hook) and recorded what happened:

document.addEventListener( 'wc-blocks_product_list_rendered', ( e ) => {
	console.log( 'rendered', e.detail ); // { collection } or {} for a hand-made collection
} );
document.addEventListener( 'wc-blocks_viewed_product', ( e ) => {
	console.log( 'viewed', e.detail ); // { collection, productId }
} );

The observed detail values were {"collection":"pocket-coffee/product-collection/club-picks"} when the club picks page first rendered, {} for the filter demo page (a collection that has no collection name), and {"productId":79} after clicking a product title. After a filter-triggered client-side navigation no further wc-blocks_product_list_rendered event was seen in the run, although the documentation says the event fires when the block "was rendered or re-rendered (for example due to a page change)". Do not rely on it to react to filter changes; observe the URL or the Interactivity store instead.

Styling is done with global styles, not with CSS selectors on the markup. A block theme styles every WooCommerce block through styles.blocks in theme.json (for example styles.blocks["woocommerce/product-price"].color), and the merchant can change the same values in the Site Editor. Block names are listed in the blocks reference. The themes page of this section explains the theme side in detail.

Pitfalls

  • A collection is only a variation. Registering one does not add server code; without the PHP filters a rule that depends on the collection name exists only in the editor’s defaults. Write the rule for both query_loop_block_query_vars and rest_product_query, or the preview and the page will disagree.

  • Run after WooCommerce. The callbacks of WooCommerce are at priority 10; yours must be later (20 in the example), otherwise WooCommerce overwrites what you set.

  • Layout names. Use flex, list and carousel; grid and stack are only names in the source.

  • The name pattern is not enforced. A name that breaks the pattern is registered with a warning, and two plugins that pick the same name silently share one collection; use the plugin slug as the first part.

  • Filters add, never replace. A Product Filters block narrows the collection’s query. A collection that fixes a category cannot be filtered to another category, and a filter value that matches nothing shows the no-results block.

  • One Product Filters block per page or template: since 10.9.0 the editor does not allow a second one. Two Product Collection blocks on one page need different queryId values, which the editor assigns.

  • Counts are cached. The filter counts and the price range are cached in transients, with a cap of 1000 entries since 10.8.0 (woocommerce_product_filter_cache_max_entries); a catalogue with many attributes can reach the cap.

  • Do not run a second product query for a block. Adjust the query through query_loop_block_query_vars; the block then keeps its pagination, counts and filters consistent.

  • The editor’s experimental function names change. Pin the WooCommerce version your extension is tested against and re-run the registration after each minor release; the experimental prefix is the only warning you get.