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