Commit 4df97e438ae for woocommerce
commit 4df97e438ae700ae1e82d83e6b5ee9bb83dd2ced
Author: Tung Du <dinhtungdu@gmail.com>
Date: Tue Oct 13 17:22:27 2026 +0700
Generalize the Product Filters URL parameter hook (#69630)
* fix: generalize the Product Filters parameter hook
* dev: add changelog for the generalized filter parameter hook
* refactor: filter product parameters after building defaults
* fix: remove outer parameter keys before unpacking
* dev: correct comment-only changelog significance
* style: align taxonomy parameter assignments
* Simplify taxonomy parameter collision checks
diff --git a/plugins/woocommerce/changelog/add-product-filter-taxonomy-params-hook b/plugins/woocommerce/changelog/add-product-filter-taxonomy-params-hook
index 71ae5bae8af..434efb763f0 100644
--- a/plugins/woocommerce/changelog/add-product-filter-taxonomy-params-hook
+++ b/plugins/woocommerce/changelog/add-product-filter-taxonomy-params-hook
@@ -1,4 +1,4 @@
Significance: minor
Type: add
-Add the woocommerce_product_filter_taxonomy_params filter so plugins can rename the URL query params (categories, tags, brands) that Product Filters has claimed since 10.2.0.
+Allow extensions to customize taxonomy URL parameter names with the woocommerce_product_filter_params filter.
diff --git a/plugins/woocommerce/changelog/fix-69597-generalize-filter-params-hook b/plugins/woocommerce/changelog/fix-69597-generalize-filter-params-hook
new file mode 100644
index 00000000000..a8fc546da1a
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-69597-generalize-filter-params-hook
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Expose the full Product Filters parameter map; only taxonomy customization is supported.
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php b/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
index 63228c667ae..b612dfd2c69 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
@@ -546,7 +546,7 @@ class FilterData {
*/
private function normalize_query_vars( array $query_vars ): array {
// Taxonomy filter params are treated as unordered sets. Read from Params so that names
- // changed through the woocommerce_product_filter_taxonomy_params filter stay normalised.
+ // changed through the woocommerce_product_filter_params filter stay normalised.
$taxonomy_set_params = array_values( $this->params->get_param( 'taxonomy' ) );
ksort( $query_vars );
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/Params.php b/plugins/woocommerce/src/Internal/ProductFilters/Params.php
index 5e4eed191f8..10b42c30263 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/Params.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/Params.php
@@ -82,7 +82,7 @@ class Params implements FilterUrlParam {
* @return void
*/
private function init_params(): void {
- $params = array(
+ $params = array(
'price' => array(
'min_price',
'max_price',
@@ -94,9 +94,39 @@ class Params implements FilterUrlParam {
'filter_stock_status',
),
'attribute' => $this->get_attribute_params(),
+ 'taxonomy' => $this->get_taxonomy_params(),
);
- $params['taxonomy'] = $this->get_taxonomy_params( $params );
- self::$params = $params;
+
+ /**
+ * Filters the complete Product Filters URL parameter map. Only changes to the `taxonomy` entry are currently applied.
+ * That entry maps taxonomy names to URL parameter names, e.g. `product_cat` => `categories`.
+ * Rename with a non-empty, unused parameter name; omitted or invalid entries keep their defaults.
+ * Register callbacks before Params is first read; the map is cached per request.
+ *
+ * @hook woocommerce_product_filter_params
+ * @since 11.3.0
+ *
+ * @param array $params Filter types (`price`, `rating`, `status`, `attribute`, `taxonomy`) mapped to their URL parameter arrays.
+ * @return array Filter types mapped to their URL parameter arrays.
+ */
+ $filtered = apply_filters( 'woocommerce_product_filter_params', $params );
+
+ if ( is_array( $filtered ) && isset( $filtered['taxonomy'] ) && is_array( $filtered['taxonomy'] ) ) {
+ $used_params = array_merge( ...array_values( array_map( 'array_values', $params ) ) );
+ foreach ( array_keys( $params['attribute'] ) as $attribute ) {
+ $used_params[] = 'query_type_' . $attribute;
+ }
+
+ foreach ( $params['taxonomy'] as $taxonomy => $default_param ) {
+ $param = $filtered['taxonomy'][ $taxonomy ] ?? null;
+ if ( is_string( $param ) && '' !== $param && ! in_array( $param, $used_params, true ) ) {
+ $params['taxonomy'][ $taxonomy ] = $param;
+ $used_params[] = $param;
+ }
+ }
+ }
+
+ self::$params = $params;
}
/**
@@ -116,10 +146,9 @@ class Params implements FilterUrlParam {
/**
* Get the taxonomy params.
*
- * @param array $other_params Params claimed by other filter types.
* @return array
*/
- private function get_taxonomy_params( array $other_params ): array {
+ private function get_taxonomy_params(): array {
$public_product_taxonomies = get_taxonomies(
array(
'public' => true,
@@ -142,37 +171,6 @@ class Params implements FilterUrlParam {
}
}
- /**
- * Filters product taxonomy URL parameters: `product_cat` => `categories`, `product_tag` => `tags`,
- * `product_brand` => `brands`; other product taxonomies use `filter_{taxonomy}`.
- * Rename with a non-empty, unused parameter name; omitted or invalid entries keep their defaults.
- * Register callbacks before Params is first read; the map is cached per request.
- *
- * @hook woocommerce_product_filter_taxonomy_params
- * @since 11.3.0
- *
- * @param array $params Map of taxonomy name to URL parameter name.
- * @return array Map of taxonomy name to URL parameter name.
- */
- $filtered = apply_filters( 'woocommerce_product_filter_taxonomy_params', $params );
-
- if ( ! is_array( $filtered ) ) {
- return $params;
- }
-
- $used_params = array_merge( array_values( $params ), ...array_values( $other_params ) );
- foreach ( array_keys( $other_params['attribute'] ) as $attribute ) {
- $used_params[] = 'query_type_' . $attribute;
- }
-
- foreach ( $params as $taxonomy => $default_param ) {
- $param = $filtered[ $taxonomy ] ?? null;
- if ( is_string( $param ) && '' !== $param && ( $param === $default_param || ! in_array( $param, $used_params, true ) ) ) {
- $params[ $taxonomy ] = $param;
- $used_params[] = $param;
- }
- }
-
return $params;
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
index cc5b1e9374c..b5fd9fdd9f7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
@@ -111,15 +111,15 @@ class FilterDataTest extends AbstractProductFiltersTest {
$key_method->setAccessible( true );
$rename = static function ( array $params ): array {
- $params['product_brand'] = 'wc_brands';
+ $params['taxonomy']['product_brand'] = 'wc_brands';
return $params;
};
- add_filter( 'woocommerce_product_filter_taxonomy_params', $rename );
+ add_filter( 'woocommerce_product_filter_params', $rename );
$renamed_key = $key_method->invoke( $this->sut, $query_vars, 'price' );
$unfiltered = (array) $this->sut->get_filtered_price( $query_vars );
- remove_filter( 'woocommerce_product_filter_taxonomy_params', $rename );
+ remove_filter( 'woocommerce_product_filter_params', $rename );
$this->clear_params_cache();
$this->assertNotSame( $renamed_key, $key_method->invoke( $this->sut, $query_vars, 'price' ) );
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/ParamsTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/ParamsTest.php
index 0c75e84f1be..6f9ed3a1d38 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/ParamsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/ParamsTest.php
@@ -26,7 +26,7 @@ class ParamsTest extends AbstractProductFiltersTest {
private $sut;
/**
- * Callback added to the woocommerce_product_filter_taxonomy_params filter during a test.
+ * Callback added to the woocommerce_product_filter_params filter during a test.
*
* @var callable|null
*/
@@ -52,7 +52,7 @@ class ParamsTest extends AbstractProductFiltersTest {
public function tearDown(): void {
try {
if ( null !== $this->taxonomy_params_filter ) {
- remove_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+ remove_filter( 'woocommerce_product_filter_params', $this->taxonomy_params_filter );
$this->taxonomy_params_filter = null;
}
} finally {
@@ -280,15 +280,19 @@ class ParamsTest extends AbstractProductFiltersTest {
* @testdox Taxonomy params can be renamed while omitted mappings keep their defaults.
*/
public function test_taxonomy_params_are_filterable_by_rename(): void {
- $this->taxonomy_params_filter = function ( array $taxonomy_params ): array {
- $this->assertSame( 'categories', $taxonomy_params['product_cat'] ?? null, 'The filter receives the default category parameter.' );
- $this->assertSame( 'tags', $taxonomy_params['product_tag'] ?? null, 'The filter receives the default tag parameter.' );
- $this->assertSame( 'brands', $taxonomy_params['product_brand'] ?? null, 'The filter receives the default brand parameter.' );
- $taxonomy_params['product_brand'] = 'wc_brands';
- unset( $taxonomy_params['product_tag'] );
- return $taxonomy_params;
+ $this->taxonomy_params_filter = function ( array $params ): array {
+ $this->assertSame( array( 'min_price', 'max_price' ), $params['price'], 'The filter receives the default price parameters.' );
+ $this->assertSame( array( 'rating_filter' ), $params['rating'], 'The filter receives the default rating parameter.' );
+ $this->assertSame( array( 'filter_stock_status' ), $params['status'], 'The filter receives the default stock parameter.' );
+ $this->assertSame( 'filter_color', $params['attribute']['color'], 'The filter receives the default attribute parameter.' );
+ $this->assertSame( 'categories', $params['taxonomy']['product_cat'] ?? null, 'The filter receives the default category parameter.' );
+ $this->assertSame( 'tags', $params['taxonomy']['product_tag'] ?? null, 'The filter receives the default tag parameter.' );
+ $this->assertSame( 'brands', $params['taxonomy']['product_brand'] ?? null, 'The filter receives the default brand parameter.' );
+ $params['taxonomy']['product_brand'] = 'wc_brands';
+ unset( $params['taxonomy']['product_tag'] );
+ return $params;
};
- add_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+ add_filter( 'woocommerce_product_filter_params', $this->taxonomy_params_filter );
$taxonomy_params = $this->sut->get_param( 'taxonomy' );
$param_keys = $this->sut->get_param_keys();
@@ -306,29 +310,47 @@ class ParamsTest extends AbstractProductFiltersTest {
public function test_taxonomy_param_collisions_keep_defaults(): void {
foreach ( array( 'categories', 'tags', 'min_price', 'max_price', 'rating_filter', 'filter_stock_status', 'filter_color', 'query_type_color' ) as $claimed_param ) {
$this->taxonomy_params_filter = static function ( array $params ) use ( $claimed_param ): array {
- $params['product_brand'] = $claimed_param;
+ $params['taxonomy']['product_brand'] = $claimed_param;
return $params;
};
- add_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+ add_filter( 'woocommerce_product_filter_params', $this->taxonomy_params_filter );
$this->assertSame( 'brands', $this->sut->get_param( 'taxonomy' )['product_brand'], "The brand filter must not claim {$claimed_param}." );
- remove_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+ remove_filter( 'woocommerce_product_filter_params', $this->taxonomy_params_filter );
$this->taxonomy_params_filter = null;
$this->clear_params_cache();
}
}
+ /**
+ * @testdox An attribute parameter remains reserved when its map key matches a product taxonomy.
+ */
+ public function test_taxonomy_rename_cannot_claim_attribute_with_same_map_key(): void {
+ $attribute_id = wc_create_attribute( array( 'name' => 'product_brand' ) );
+ $this->assertNotWPError( $attribute_id, 'The attribute fixture should be created.' );
+
+ $this->taxonomy_params_filter = static function ( array $params ): array {
+ $params['taxonomy']['product_brand'] = 'filter_product_brand';
+ return $params;
+ };
+ add_filter( 'woocommerce_product_filter_params', $this->taxonomy_params_filter );
+
+ $this->assertSame( 'brands', $this->sut->get_param( 'taxonomy' )['product_brand'], 'A taxonomy cannot claim the attribute parameter with the same map key.' );
+ $this->assertSame( 'filter_product_brand', $this->sut->get_param( 'attribute' )['product_brand'], 'The original attribute parameter remains unchanged.' );
+ $this->assertContains( 'brands', $this->sut->get_param_keys(), 'Parameter initialization must retain the default brand key.' );
+ }
+
/**
* @testdox Two taxonomies cannot be renamed to the same new parameter.
*/
public function test_duplicate_taxonomy_renames_keep_one_default(): void {
$this->taxonomy_params_filter = static function ( array $params ): array {
- $params['product_tag'] = 'wc_shared';
- $params['product_brand'] = 'wc_shared';
+ $params['taxonomy']['product_tag'] = 'wc_shared';
+ $params['taxonomy']['product_brand'] = 'wc_shared';
return $params;
};
- add_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+ add_filter( 'woocommerce_product_filter_params', $this->taxonomy_params_filter );
$taxonomy_params = $this->sut->get_param( 'taxonomy' );
$this->assertSame( 1, array_count_values( $taxonomy_params )['wc_shared'] ?? 0 );
@@ -340,15 +362,15 @@ class ParamsTest extends AbstractProductFiltersTest {
*/
public function test_taxonomy_params_filter_runs_once_before_cache_is_warm(): void {
$calls = 0;
- $this->taxonomy_params_filter = function ( array $taxonomy_params ) use ( &$calls ): array {
+ $this->taxonomy_params_filter = function ( array $params ) use ( &$calls ): array {
++$calls;
- $taxonomy_params['product_brand'] = 'wc_brands';
- return $taxonomy_params;
+ $params['taxonomy']['product_brand'] = 'wc_brands';
+ return $params;
};
- add_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+ add_filter( 'woocommerce_product_filter_params', $this->taxonomy_params_filter );
$this->assertSame( 'wc_brands', $this->sut->get_param( 'taxonomy' )['product_brand'] );
- remove_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+ remove_filter( 'woocommerce_product_filter_params', $this->taxonomy_params_filter );
$this->taxonomy_params_filter = null;
$this->assertSame( 'wc_brands', $this->sut->get_param( 'taxonomy' )['product_brand'], 'The cached map remains stable after the callback is removed.' );
@@ -356,47 +378,55 @@ class ParamsTest extends AbstractProductFiltersTest {
}
/**
- * @testdox A taxonomy params callback that returns a non-array value falls back to the unfiltered map.
+ * @testdox An invalid callback result or missing taxonomy map keeps the default taxonomy parameters.
*
* @testWith ["null"]
* ["string"]
* ["false"]
+ * ["missing taxonomy"]
+ * ["invalid taxonomy"]
*
- * @param string $return_type Which non-array value the callback returns.
+ * @param string $return_type Which callback result to return.
*/
- public function test_taxonomy_params_filter_falls_back_when_callback_returns_a_non_array( string $return_type ): void {
+ public function test_taxonomy_params_filter_falls_back_when_callback_returns_an_invalid_map( string $return_type ): void {
$return_values = array(
- 'null' => null,
- 'string' => 'brands',
- 'false' => false,
+ 'null' => null,
+ 'string' => 'brands',
+ 'false' => false,
+ 'missing taxonomy' => array(),
+ 'invalid taxonomy' => array( 'taxonomy' => 'brands' ),
);
$this->taxonomy_params_filter = static function () use ( $return_values, $return_type ) {
return $return_values[ $return_type ];
};
- add_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+ add_filter( 'woocommerce_product_filter_params', $this->taxonomy_params_filter );
$taxonomy_params = $this->sut->get_param( 'taxonomy' );
$this->assertSame( 'categories', $taxonomy_params['product_cat'] ?? null, "A callback returning {$return_type} should leave the default map in place." );
$this->assertSame( 'brands', $taxonomy_params['product_brand'] ?? null, "A callback returning {$return_type} should leave the default map in place." );
- $this->assertContains( 'categories', $this->sut->get_param_keys(), 'get_param_keys() should still resolve after a callback returns a non-array.' );
+ $this->assertContains( 'categories', $this->sut->get_param_keys(), 'get_param_keys() should still resolve after a callback returns an invalid map.' );
}
/**
* @testdox Invalid taxonomy param values keep their defaults and unknown taxonomies are ignored.
*/
public function test_taxonomy_params_filter_ignores_invalid_entries(): void {
- $this->taxonomy_params_filter = static function ( array $taxonomy_params ): array {
- $taxonomy_params['product_cat'] = array( 'categories', 'cats' );
- $taxonomy_params['product_tag'] = 42;
- $taxonomy_params['product_brand'] = '';
- $taxonomy_params['post_tag'] = 'post_tags';
- $taxonomy_params[] = 'orphan';
-
- return $taxonomy_params;
+ $this->taxonomy_params_filter = static function ( array $params ): array {
+ $params['taxonomy']['product_cat'] = array( 'categories', 'cats' );
+ $params['taxonomy']['product_tag'] = 42;
+ $params['taxonomy']['product_brand'] = '';
+ $params['taxonomy']['post_tag'] = 'post_tags';
+ $params['price'] = array( 'custom_min', 'custom_max' );
+ $params['rating'] = array( 'custom_rating' );
+ $params['status'] = array( 'custom_stock' );
+ $params['attribute'] = array( 'color' => 'custom_color' );
+ $params['taxonomy'][] = 'orphan';
+
+ return $params;
};
- add_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+ add_filter( 'woocommerce_product_filter_params', $this->taxonomy_params_filter );
$taxonomy_params = $this->sut->get_param( 'taxonomy' );
$param_keys = $this->sut->get_param_keys();
@@ -406,6 +436,10 @@ class ParamsTest extends AbstractProductFiltersTest {
$this->assertSame( 'brands', $taxonomy_params['product_brand'], 'An empty value must not disable the brand filter.' );
$this->assertArrayNotHasKey( 'post_tag', $taxonomy_params, 'Taxonomies outside the product map should be discarded.' );
$this->assertArrayNotHasKey( 0, $taxonomy_params, 'A numerically keyed entry should be discarded.' );
+ $this->assertSame( array( 'min_price', 'max_price' ), $this->sut->get_param( 'price' ), 'The hook must not customize price parameters.' );
+ $this->assertSame( array( 'rating_filter' ), $this->sut->get_param( 'rating' ), 'The hook must not customize rating parameters.' );
+ $this->assertSame( array( 'filter_stock_status' ), $this->sut->get_param( 'status' ), 'The hook must not customize stock parameters.' );
+ $this->assertSame( 'filter_color', $this->sut->get_param( 'attribute' )['color'], 'The hook must not customize attribute parameters.' );
foreach ( $param_keys as $param_key ) {
$this->assertIsString( $param_key, 'get_param_keys() must only expose strings, since they become public query vars.' );
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/QueryClausesTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/QueryClausesTest.php
index d0a3893523e..96141f38dea 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/QueryClausesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/QueryClausesTest.php
@@ -26,7 +26,7 @@ class QueryClausesTest extends AbstractProductFiltersTest {
private $sut;
/**
- * Callback added to the woocommerce_product_filter_taxonomy_params filter during a test.
+ * Callback added to the woocommerce_product_filter_params filter during a test.
*
* @var callable|null
*/
@@ -53,7 +53,7 @@ class QueryClausesTest extends AbstractProductFiltersTest {
public function tearDown(): void {
try {
if ( null !== $this->taxonomy_params_filter ) {
- remove_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+ remove_filter( 'woocommerce_product_filter_params', $this->taxonomy_params_filter );
$this->taxonomy_params_filter = null;
}
} finally {
@@ -441,11 +441,11 @@ class QueryClausesTest extends AbstractProductFiltersTest {
$brand_owner = $this->products[0];
$brand_slug = $this->assign_brand_to_product( $brand_owner, 'Acme' );
- $this->taxonomy_params_filter = function ( array $taxonomy_params ): array {
- $taxonomy_params['product_brand'] = 'wc_brands';
- return $taxonomy_params;
+ $this->taxonomy_params_filter = function ( array $params ): array {
+ $params['taxonomy']['product_brand'] = 'wc_brands';
+ return $params;
};
- add_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+ add_filter( 'woocommerce_product_filter_params', $this->taxonomy_params_filter );
list( $where, $posts ) = $this->query_main_products( array( 'brands' => $brand_slug ) );