Commit 0b5afedcd22 for woocommerce

commit 0b5afedcd2281d6c988c93ebd20a549d4a25fa35
Author: rakshitwesley <95758851+rakshitwesley@users.noreply.github.com>
Date:   Mon Sep 28 11:24:22 2026 +0400

    Add woocommerce_product_filter_taxonomy_params so plugins can rename core's reserved filter params (#68904)

    * Add failing tests for filterable Product Filters taxonomy params

    Adds the red half of the red/green proof for a new
    woocommerce_product_filter_taxonomy_params filter: rename, release,
    callback shape, read-time application after the static cache is warm,
    and a causal main-query regression test plus a control that locks in
    the current fail-closed AND 1=0 default.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

    * Allow plugins to rename Product Filters taxonomy URL params

    Since 10.2.0 Internal\ProductFilters has claimed the bare query params
    'categories', 'tags' and 'brands'. A third party using the same URL key
    gets blanked product archives, because add_taxonomy_clauses() appends
    AND 1=0 when the value does not resolve to a term in core's taxonomy.

    Add the woocommerce_product_filter_taxonomy_params filter so the claim
    can be renamed or released. The filter is applied when the map is read
    rather than inside the static cache, so a callback registered after the
    cache is warm still takes effect.

    Source the taxonomy short-names in FilterData::normalize_query_vars()
    from Params so a renamed param keeps cache-key set-normalisation.

    No change to the default parameter names or to the AND 1=0 behaviour.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

    * Address review: guard filter output, make tests causal, require Params

    - Guard the filter result: fall back to the unfiltered map when a callback
      returns a non-array, and keep only string keys/values. Unvalidated output
      reached WP::public_query_vars and could fatal every front-end request.
    - Document that the callback must register before parse_request, since
      query_vars is a one-shot filter.
    - Make the main-query regression test self-causal and reset the Params
      static cache in setUp, so it cannot pass when product_brand is absent.
    - Add main-query coverage for rename, the documented preferred usage.
    - Make FilterData's Params argument required and drop the container
      fallback; inject a stub in FilterDataNormalisationTest so it no longer
      depends on global taxonomy registration.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

    * Address review: changelog scope, docblock length, shared cache helper

    - Changelog now describes the mechanism rather than claiming the collision
      is resolved. With no callback registered nothing changes for merchants
      until their filter plugin adopts the hook, and release notes are
      merchant-facing.
    - Trim the hook docblock from 27 lines to 18, in line with the sibling
      hooks in FilterData.php. Keeps the map shape, the rename-over-removal
      advice, the parse_request timing gotcha, and one sentence on the guard.
    - Hoist clear_params_cache() into AbstractProductFiltersTest and call it
      from the base setUp(), so every ProductFilters test class starts with a
      cold static cache instead of inheriting a warm one.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

    * Make the changelog searchable and mark the hook as public API

    - Name the affected params and the version they were claimed in, so a
      merchant searching release notes for the 10.2.0 behaviour finds this,
      without claiming their store is fixed by the update alone.
    - State in the docblock that the hook is public API for extension
      authors, since the enclosing class carries an @internal notice that
      could be read as covering the filter too.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

    * Review: complete the default map in the docblock, note the front-page caveat

    - The documented default map omitted product_brand => brands, which is the
      parameter this hook exists for, and the filter_{taxonomy} rule for other
      public product taxonomies.
    - Note that a plugin which also registers the parameter as a public query
      var loses front-page routing on a Shop-on-front site when it releases or
      renames. Verified on a live store: a plugin reading $_GET is unaffected
      (3 correct products), one registering the query var falls back to the
      blog index.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

    * fix: keep taxonomy filters when URL mapping omits entries

    * docs: clarify taxonomy URL parameter changelog

    * docs: list default product taxonomy URL parameters

    * fix: reject conflicting taxonomy filter URL names

    * refactor: simplify taxonomy URL parameter collision checks

    * test: consolidate taxonomy parameter coverage

    * fix: key product filter data by taxonomy URL mapping

    * fix: key filter data by complete parameter map

    * test: allow PHP 7.4 to invoke private cache methods

    * test: limit taxonomy query coverage to renamed parameters

    ---------

    Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
    Co-authored-by: Tung Du <dinhtungdu@gmail.com>

diff --git a/plugins/woocommerce/changelog/add-product-filter-taxonomy-params-hook b/plugins/woocommerce/changelog/add-product-filter-taxonomy-params-hook
new file mode 100644
index 00000000000..71ae5bae8af
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-filter-taxonomy-params-hook
@@ -0,0 +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.
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php b/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
index 33294faf3d7..7cfb40a313b 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/FilterData.php
@@ -30,15 +30,24 @@ class FilterData {
 	 */
 	private $taxonomy_hierarchy_data;

+	/**
+	 * Instance of Params.
+	 *
+	 * @var Params
+	 */
+	private $params;
+
 	/**
 	 * Constructor.
 	 *
 	 * @param QueryClausesGenerator $query_clauses Instance of QueryClausesGenerator.
 	 * @param TaxonomyHierarchyData $taxonomy_hierarchy_data Instance of TaxonomyHierarchyData.
+	 * @param Params                $params Instance of Params.
 	 */
-	public function __construct( QueryClausesGenerator $query_clauses, TaxonomyHierarchyData $taxonomy_hierarchy_data ) {
+	public function __construct( QueryClausesGenerator $query_clauses, TaxonomyHierarchyData $taxonomy_hierarchy_data, Params $params ) {
 		$this->query_clauses           = $query_clauses;
 		$this->taxonomy_hierarchy_data = $taxonomy_hierarchy_data;
+		$this->params                  = $params;
 	}

 	/**
@@ -486,6 +495,7 @@ class FilterData {
 				wp_json_encode(
 					array(
 						'query_vars'  => $this->normalize_query_vars( $query_vars ),
+						'params'      => $this->get_params_for_cache(),
 						'extra'       => $extra,
 						'filter_type' => $filter_type,
 					)
@@ -501,8 +511,8 @@ class FilterData {
 	 * Rules applied (cache key only – the original $query_vars are never modified):
 	 * - All keys are sorted alphabetically (ksort).
 	 * - Values for keys that start with "filter_", equal "rating_filter", or are
-	 *   built-in taxonomy short-names ("categories", "tags", "brands"):
-	 *   comma-separated items are trimmed, lower-cased, sorted, then re-joined.
+	 *   one of the taxonomy filter params: comma-separated items are trimmed,
+	 *   lower-cased, sorted, then re-joined.
 	 * - Values for keys that start with "query_type_": trimmed and lower-cased.
 	 * - Values for "min_price" / "max_price": trimmed.
 	 *
@@ -512,9 +522,9 @@ class FilterData {
 	 * @return array Normalised copy of $query_vars.
 	 */
 	private function normalize_query_vars( array $query_vars ): array {
-		// Built-in taxonomy filter params that are treated as unordered sets.
-		// See Params::get_taxonomy_params() for the source of these short names.
-		$taxonomy_set_params = array( 'categories', 'tags', 'brands' );
+		// 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.
+		$taxonomy_set_params = array_values( $this->params->get_param( 'taxonomy' ) );

 		ksort( $query_vars );

@@ -539,6 +549,22 @@ class FilterData {
 		return $query_vars;
 	}

+	/**
+	 * Get the complete parameter map in a stable order for cache keys.
+	 *
+	 * @return array Filter types mapped to their URL parameters.
+	 */
+	private function get_params_for_cache(): array {
+		$params = $this->params->get_params();
+		foreach ( $params as &$type_params ) {
+			ksort( $type_params );
+		}
+		unset( $type_params );
+		ksort( $params );
+
+		return $params;
+	}
+
 	/**
 	 * Get cached filter data.
 	 *
@@ -637,7 +663,14 @@ class FilterData {
 	 * @return string Comma-separated list of product IDs.
 	 */
 	private function get_cached_product_ids( array $query_vars ) {
-		$cache_key = WC_Cache_Helper::get_cache_prefix( CacheController::CACHE_GROUP ) . md5( wp_json_encode( $this->normalize_query_vars( $query_vars ) ) );
+		$cache_key = WC_Cache_Helper::get_cache_prefix( CacheController::CACHE_GROUP ) . md5(
+			wp_json_encode(
+				array(
+					'query_vars' => $this->normalize_query_vars( $query_vars ),
+					'params'     => $this->get_params_for_cache(),
+				)
+			)
+		);
 		$cache     = wp_cache_get( $cache_key );

 		if ( $cache ) {
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/FilterDataProvider.php b/plugins/woocommerce/src/Internal/ProductFilters/FilterDataProvider.php
index 9cc7f18dbc5..9c1b107d863 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/FilterDataProvider.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/FilterDataProvider.php
@@ -32,17 +32,26 @@ class FilterDataProvider {
 	 */
 	private $taxonomy_hierarchy_data;

+	/**
+	 * Instance of Params.
+	 *
+	 * @var Params
+	 */
+	private $params;
+
 	/**
 	 * Initialize dependencies.
 	 *
 	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
 	 *
 	 * @param TaxonomyHierarchyData $taxonomy_hierarchy_data Instance of TaxonomyHierarchyData.
+	 * @param Params                $params Instance of Params.
 	 *
 	 * @return void
 	 */
-	final public function init( TaxonomyHierarchyData $taxonomy_hierarchy_data ): void {
+	final public function init( TaxonomyHierarchyData $taxonomy_hierarchy_data, Params $params ): void {
 		$this->taxonomy_hierarchy_data = $taxonomy_hierarchy_data;
+		$this->params                  = $params;
 	}

 	/**
@@ -54,7 +63,7 @@ class FilterDataProvider {
 		$class_name = get_class( $query_clauses_generator );

 		if ( ! isset( $this->providers[ $class_name ] ) ) {
-			$this->providers[ $class_name ] = new FilterData( $query_clauses_generator, $this->taxonomy_hierarchy_data );
+			$this->providers[ $class_name ] = new FilterData( $query_clauses_generator, $this->taxonomy_hierarchy_data, $this->params );
 		}

 		return $this->providers[ $class_name ];
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/Params.php b/plugins/woocommerce/src/Internal/ProductFilters/Params.php
index 64f1ef7c244..5e4eed191f8 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/Params.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/Params.php
@@ -32,9 +32,9 @@ class Params implements FilterUrlParam {
 		}

 		$keys = array();
-		foreach ( self::$params as $taxonomy => $params ) {
+		foreach ( self::$params as $type => $params ) {
 			$keys = array_merge( $keys, array_values( $params ) );
-			if ( 'attribute' === $taxonomy ) {
+			if ( 'attribute' === $type ) {
 				$query_type_params = array_map(
 					function ( $param ) {
 						return 'query_type_' . $param;
@@ -62,13 +62,27 @@ class Params implements FilterUrlParam {
 		return self::$params[ $type ] ?? array();
 	}

+	/**
+	 * Get the complete effective parameter map.
+	 *
+	 * @since 11.3.0
+	 * @return array Filter types mapped to their URL parameters.
+	 */
+	public function get_params(): array {
+		if ( empty( self::$params ) ) {
+			$this->init_params();
+		}
+
+		return self::$params;
+	}
+
 	/**
 	 * Initialize the params.
 	 *
 	 * @return void
 	 */
 	private function init_params(): void {
-		self::$params = array(
+		$params             = array(
 			'price'     => array(
 				'min_price',
 				'max_price',
@@ -80,8 +94,9 @@ 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;
 	}

 	/**
@@ -101,9 +116,10 @@ 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 {
+	private function get_taxonomy_params( array $other_params ): array {
 		$public_product_taxonomies = get_taxonomies(
 			array(
 				'public'  => true,
@@ -112,8 +128,7 @@ class Params implements FilterUrlParam {
 			'objects'
 		);

-		// We have control over built-in taxonomies, so we can use prettier names.
-		$map = array(
+		$built_in_taxonomies = array(
 			'product_cat'   => 'categories',
 			'product_tag'   => 'tags',
 			'product_brand' => 'brands',
@@ -123,7 +138,38 @@ class Params implements FilterUrlParam {

 		foreach ( $public_product_taxonomies as $taxonomy ) {
 			if ( is_array( $taxonomy->object_type ) && in_array( 'product', $taxonomy->object_type, true ) ) {
-				$params[ $taxonomy->name ] = $map[ $taxonomy->name ] ?? "filter_$taxonomy->name";
+				$params[ $taxonomy->name ] = $built_in_taxonomies[ $taxonomy->name ] ?? "filter_$taxonomy->name";
+			}
+		}
+
+		/**
+		 * 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;
 			}
 		}

diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/AbstractProductFiltersTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/AbstractProductFiltersTest.php
index f27825dd050..f2a79450346 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/AbstractProductFiltersTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/AbstractProductFiltersTest.php
@@ -3,6 +3,7 @@ declare(strict_types=1);

 namespace Automattic\WooCommerce\Tests\Internal\ProductFilters;

+use Automattic\WooCommerce\Internal\ProductFilters\Params;
 use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
 use WC_Product;
 use WC_Product_Variable;
@@ -117,6 +118,19 @@ abstract class AbstractProductFiltersTest extends \WC_Unit_Test_Case {
 		} else {
 			$this->set_up_product_filter_fixtures();
 		}
+
+		// The map is static, so it can arrive already warmed by whichever class ran before this one.
+		$this->clear_params_cache();
+	}
+
+	/**
+	 * Reset the static taxonomy param map held by Params.
+	 */
+	protected function clear_params_cache(): void {
+		$reflection      = new \ReflectionClass( Params::class );
+		$params_property = $reflection->getProperty( 'params' );
+		$params_property->setAccessible( true );
+		$params_property->setValue( array() );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataNormalisationTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataNormalisationTest.php
index 7c4e28927e5..5acf1998749 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataNormalisationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataNormalisationTest.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Internal\ProductFilters;

 use Automattic\WooCommerce\Internal\ProductFilters\FilterData;
 use Automattic\WooCommerce\Internal\ProductFilters\Interfaces\QueryClausesGenerator;
+use Automattic\WooCommerce\Internal\ProductFilters\Params;
 use Automattic\WooCommerce\Internal\ProductFilters\TaxonomyHierarchyData;

 /**
@@ -14,6 +15,26 @@ use Automattic\WooCommerce\Internal\ProductFilters\TaxonomyHierarchyData;
  */
 class FilterDataNormalisationTest extends \WC_Unit_Test_Case {

+	/**
+	 * Taxonomy param map the stubbed Params returns, so normalisation does not depend on
+	 * which taxonomies happen to be registered when the real Params cache is warmed.
+	 */
+	private const TAXONOMY_PARAMS = array(
+		'product_cat'   => 'categories',
+		'product_tag'   => 'tags',
+		'product_brand' => 'wc_brands',
+	);
+
+	/**
+	 * Full effective parameter map returned by the Params stub.
+	 *
+	 * @var array
+	 */
+	private $cache_params = array(
+		'price'    => array( 'min_price', 'max_price' ),
+		'taxonomy' => self::TAXONOMY_PARAMS,
+	);
+
 	/**
 	 * The private method under test, exposed via reflection.
 	 *
@@ -37,7 +58,19 @@ class FilterDataNormalisationTest extends \WC_Unit_Test_Case {
 		$query_clauses           = $this->createMock( QueryClausesGenerator::class );
 		$taxonomy_hierarchy_data = $this->createMock( TaxonomyHierarchyData::class );

-		$this->sut = new FilterData( $query_clauses, $taxonomy_hierarchy_data );
+		$params = $this->createMock( Params::class );
+		$params->method( 'get_param' )->willReturnCallback(
+			static function ( string $type ): array {
+				return 'taxonomy' === $type ? self::TAXONOMY_PARAMS : array();
+			}
+		);
+		$params->method( 'get_params' )->willReturnCallback(
+			function (): array {
+				return $this->cache_params;
+			}
+		);
+
+		$this->sut = new FilterData( $query_clauses, $taxonomy_hierarchy_data, $params );

 		$reflection      = new \ReflectionClass( FilterData::class );
 		$this->normalize = $reflection->getMethod( 'normalize_query_vars' );
@@ -54,6 +87,23 @@ class FilterDataNormalisationTest extends \WC_Unit_Test_Case {
 		return $this->normalize->invoke( $this->sut, $query_vars );
 	}

+	/**
+	 * @testdox Cache keys depend on all filter params, not only taxonomy names or registration order.
+	 */
+	public function test_cache_key_uses_complete_parameter_map(): void {
+		$key_method = new \ReflectionMethod( $this->sut, 'get_transient_key' );
+		$key_method->setAccessible( true );
+		$query_vars = array( 'post_type' => 'product' );
+		$initial    = $key_method->invoke( $this->sut, $query_vars, 'price' );
+
+		$this->cache_params             = array_reverse( $this->cache_params, true );
+		$this->cache_params['taxonomy'] = array_reverse( $this->cache_params['taxonomy'], true );
+		$this->assertSame( $initial, $key_method->invoke( $this->sut, $query_vars, 'price' ) );
+
+		$this->cache_params['price'][0] = 'custom_min_price';
+		$this->assertNotSame( $initial, $key_method->invoke( $this->sut, $query_vars, 'price' ) );
+	}
+
 	/**
 	 * @testdox Keys are sorted alphabetically regardless of insertion order.
 	 */
@@ -95,22 +145,25 @@ class FilterDataNormalisationTest extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Built-in taxonomy short-name params (categories, tags, brands) are normalised as sets.
+	 * @testdox Every taxonomy param reported by Params is normalised as a set.
 	 */
 	public function test_taxonomy_set_params_are_normalised(): void {
-		$a = $this->normalize( array( 'categories' => 'shirts,hats' ) );
-		$b = $this->normalize( array( 'categories' => 'hats,shirts' ) );
+		foreach ( self::TAXONOMY_PARAMS as $taxonomy => $param ) {
+			$a = $this->normalize( array( $param => ' Shirts , hats ' ) );
+			$b = $this->normalize( array( $param => 'hats,Shirts' ) );

-		$this->assertSame( $a['categories'], $b['categories'] );
-		$this->assertSame( 'hats,shirts', $a['categories'] );
+			$this->assertSame( 'hats,shirts', $a[ $param ], "Values of the {$taxonomy} param ({$param}) should be trimmed, lowercased and sorted." );
+			$this->assertSame( $a[ $param ], $b[ $param ], "Equivalent {$param} values in different orders should normalise identically." );
+		}
+	}

-		$a = $this->normalize( array( 'tags' => 'sale,new' ) );
-		$b = $this->normalize( array( 'tags' => 'new,sale' ) );
-		$this->assertSame( $a['tags'], $b['tags'] );
+	/**
+	 * @testdox A taxonomy param absent from the Params map is left unchanged.
+	 */
+	public function test_unknown_taxonomy_set_param_is_not_normalised(): void {
+		$result = $this->normalize( array( 'brands' => 'nike,adidas' ) );

-		$a = $this->normalize( array( 'brands' => 'nike,adidas' ) );
-		$b = $this->normalize( array( 'brands' => 'adidas,nike' ) );
-		$this->assertSame( $a['brands'], $b['brands'] );
+		$this->assertSame( 'nike,adidas', $result['brands'], 'Only params reported by Params should be treated as unordered sets.' );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
index 19603cf6e6e..34fca6f359b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/FilterDataTest.php
@@ -6,6 +6,8 @@ use Automattic\WooCommerce\Internal\ProductFilters\FilterDataProvider;
 use Automattic\WooCommerce\Internal\ProductFilters\QueryClauses;
 use Automattic\WooCommerce\Internal\ProductFilters\TaxonomyHierarchyData;

+require_once WC_ABSPATH . '/includes/class-wc-brands.php';
+
 /**
  * Tests related to Counts service.
  */
@@ -61,6 +63,42 @@ class FilterDataTest extends AbstractProductFiltersTest {
 		$this->test_get_filtered_price_with( $wp_query );
 	}

+	/**
+	 * @testdox Changing the taxonomy URL map cannot reuse filter-data or product-ID cache entries.
+	 */
+	public function test_taxonomy_param_map_changes_filter_data_cache_keys(): void {
+		\WC_Brands::init_taxonomy();
+		$this->clear_params_cache();
+
+		$brand = wp_insert_term( 'Cache key brand', 'product_brand' );
+		$this->assertIsArray( $brand );
+		wp_set_object_terms( $this->products[0]->get_id(), (int) $brand['term_id'], 'product_brand' );
+
+		$query_vars = array(
+			'post_type'   => 'product',
+			'product_cat' => 'cat-1',
+			'brands'      => get_term( (int) $brand['term_id'], 'product_brand' )->slug,
+		);
+		$key_method = new \ReflectionMethod( $this->sut, 'get_transient_key' );
+		$key_method->setAccessible( true );
+
+		$rename = static function ( array $params ): array {
+			$params['product_brand'] = 'wc_brands';
+			return $params;
+		};
+		add_filter( 'woocommerce_product_filter_taxonomy_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 );
+		$this->clear_params_cache();
+
+		$this->assertNotSame( $renamed_key, $key_method->invoke( $this->sut, $query_vars, 'price' ) );
+		$this->assertGreaterThan( 10, (float) $unfiltered['max_price'] );
+		$this->assertSame( 10.0, (float) $this->sut->get_filtered_price( $query_vars )['max_price'] );
+	}
+
 	/**
 	 * @testdox Test price range with stock filter set to instock.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/ParamsTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/ParamsTest.php
index 9062e3826ae..0c75e84f1be 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/ParamsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/ParamsTest.php
@@ -25,6 +25,13 @@ class ParamsTest extends AbstractProductFiltersTest {
 	 */
 	private $sut;

+	/**
+	 * Callback added to the woocommerce_product_filter_taxonomy_params filter during a test.
+	 *
+	 * @var callable|null
+	 */
+	private $taxonomy_params_filter;
+
 	/**
 	 * Runs before each test.
 	 */
@@ -39,6 +46,20 @@ class ParamsTest extends AbstractProductFiltersTest {
 		$this->clear_params_cache();
 	}

+	/**
+	 * Runs after each test.
+	 */
+	public function tearDown(): void {
+		try {
+			if ( null !== $this->taxonomy_params_filter ) {
+				remove_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+				$this->taxonomy_params_filter = null;
+			}
+		} finally {
+			parent::tearDown();
+		}
+	}
+
 	/**
 	 * Test that get_param_keys returns all expected parameter types.
 	 */
@@ -77,6 +98,7 @@ class ParamsTest extends AbstractProductFiltersTest {
 		$params = $this->sut->get_param( $type );

 		$this->assertIsArray( $params );
+		$this->assertSame( $params, $this->sut->get_params()[ $type ] ?? array() );

 		if ( ! empty( $expected_structure ) ) {
 			foreach ( $expected_structure as $expected_param ) {
@@ -255,12 +277,138 @@ class ParamsTest extends AbstractProductFiltersTest {
 	}

 	/**
-	 * Helper method to clear params cache for testing.
+	 * @testdox Taxonomy params can be renamed while omitted mappings keep their defaults.
 	 */
-	private function clear_params_cache() {
-		$reflection      = new \ReflectionClass( Params::class );
-		$params_property = $reflection->getProperty( 'params' );
-		$params_property->setAccessible( true );
-		$params_property->setValue( array() );
+	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;
+		};
+		add_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+
+		$taxonomy_params = $this->sut->get_param( 'taxonomy' );
+		$param_keys      = $this->sut->get_param_keys();
+
+		$this->assertSame( 'wc_brands', $taxonomy_params['product_brand'], 'Renamed taxonomy param should use the new key.' );
+		$this->assertSame( 'tags', $taxonomy_params['product_tag'], 'Omitted taxonomy param should keep its default.' );
+		$this->assertContains( 'tags', $param_keys, 'get_param_keys() should retain the omitted taxonomy param.' );
+		$this->assertContains( 'wc_brands', $param_keys, 'get_param_keys() should expose the renamed param.' );
+		$this->assertNotContains( 'brands', $param_keys, 'get_param_keys() should not expose the original param name once renamed.' );
+	}
+
+	/**
+	 * @testdox A taxonomy cannot claim another filter's parameter.
+	 */
+	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;
+				return $params;
+			};
+			add_filter( 'woocommerce_product_filter_taxonomy_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 );
+			$this->taxonomy_params_filter = null;
+			$this->clear_params_cache();
+		}
+	}
+
+	/**
+	 * @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';
+			return $params;
+		};
+		add_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+
+		$taxonomy_params = $this->sut->get_param( 'taxonomy' );
+		$this->assertSame( 1, array_count_values( $taxonomy_params )['wc_shared'] ?? 0 );
+		$this->assertTrue( 'tags' === $taxonomy_params['product_tag'] || 'brands' === $taxonomy_params['product_brand'], 'A colliding rename must keep its default.' );
+	}
+
+	/**
+	 * @testdox The taxonomy params filter runs once before the map is cached.
+	 */
+	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 {
+			++$calls;
+			$taxonomy_params['product_brand'] = 'wc_brands';
+			return $taxonomy_params;
+		};
+		add_filter( 'woocommerce_product_filter_taxonomy_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 );
+		$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.' );
+		$this->assertSame( 1, $calls, 'The filter should only run when the map is initialized.' );
+	}
+
+	/**
+	 * @testdox A taxonomy params callback that returns a non-array value falls back to the unfiltered map.
+	 *
+	 * @testWith ["null"]
+	 *           ["string"]
+	 *           ["false"]
+	 *
+	 * @param string $return_type Which non-array value the callback returns.
+	 */
+	public function test_taxonomy_params_filter_falls_back_when_callback_returns_a_non_array( string $return_type ): void {
+		$return_values = array(
+			'null'   => null,
+			'string' => 'brands',
+			'false'  => false,
+		);
+
+		$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 );
+
+		$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.' );
+	}
+
+	/**
+	 * @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;
+		};
+		add_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+
+		$taxonomy_params = $this->sut->get_param( 'taxonomy' );
+		$param_keys      = $this->sut->get_param_keys();
+
+		$this->assertSame( 'categories', $taxonomy_params['product_cat'], 'An array value must not disable the category filter.' );
+		$this->assertSame( 'tags', $taxonomy_params['product_tag'], 'A non-string value must not disable the tag filter.' );
+		$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.' );
+
+		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 ce87f2c2c02..c190a60942a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/QueryClausesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/QueryClausesTest.php
@@ -5,6 +5,8 @@ namespace Automattic\WooCommerce\Tests\Internal\ProductFilters;

 use Automattic\WooCommerce\Internal\ProductFilters\QueryClauses;

+require_once WC_ABSPATH . '/includes/class-wc-brands.php';
+
 /**
  * Tests related to QueryClauses service.
  */
@@ -23,14 +25,40 @@ class QueryClausesTest extends AbstractProductFiltersTest {
 	 */
 	private $sut;

+	/**
+	 * Callback added to the woocommerce_product_filter_taxonomy_params filter during a test.
+	 *
+	 * @var callable|null
+	 */
+	private $taxonomy_params_filter;
+
 	/**
 	 * Runs before each test.
 	 */
 	public function setUp(): void {
 		parent::setUp();

-		$container = wc_get_container();
-		$this->sut = $container->get( QueryClauses::class );
+		// Ensure brands taxonomy is registered for testing.
+		\WC_Brands::init_taxonomy();
+
+		$this->sut = wc_get_container()->get( QueryClauses::class );
+
+		// The static map may have been warmed by another test class before product_brand existed.
+		$this->clear_params_cache();
+	}
+
+	/**
+	 * Runs after each test.
+	 */
+	public function tearDown(): void {
+		try {
+			if ( null !== $this->taxonomy_params_filter ) {
+				remove_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+				$this->taxonomy_params_filter = null;
+			}
+		} finally {
+			parent::tearDown();
+		}
 	}

 	/**
@@ -316,6 +344,95 @@ class QueryClausesTest extends AbstractProductFiltersTest {
 		}
 	}

+	/**
+	 * @testdox Renaming a taxonomy filter param releases the old param and filters on the new one.
+	 */
+	public function test_renamed_taxonomy_param_is_used_on_main_query(): void {
+		$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;
+		};
+		add_filter( 'woocommerce_product_filter_taxonomy_params', $this->taxonomy_params_filter );
+
+		list( $where, $posts ) = $this->query_main_products( array( 'brands' => $brand_slug ) );
+
+		$this->assertStringNotContainsString( 'AND 1=0', $where, 'The released param must no longer reach the taxonomy clauses.' );
+		$this->assertEqualsCanonicalizing(
+			$this->get_data_from_products_array( $this->products ),
+			$this->get_data_from_products_array( array_map( 'wc_get_product', $posts ) ),
+			'Once renamed, the old param must be ignored rather than filtering the query.'
+		);
+
+		list( $where, $posts ) = $this->query_main_products( array( 'wc_brands' => $brand_slug ) );
+
+		$this->assertStringNotContainsString( 'AND 1=0', $where, 'The renamed param matches a real term, so the query must not fail closed.' );
+		$this->assertSame(
+			array( $brand_owner->get_name() ),
+			$this->get_data_from_products_array( array_map( 'wc_get_product', $posts ) ),
+			'The renamed param should filter on product_brand exactly as the original param did.'
+		);
+	}
+
+	/**
+	 * Create a product brand and assign it to a single product.
+	 *
+	 * @param \WC_Product $product    Product to assign the brand to.
+	 * @param string      $brand_name Brand name.
+	 * @return string The brand slug.
+	 */
+	private function assign_brand_to_product( \WC_Product $product, string $brand_name ): string {
+		$term = wp_insert_term( $brand_name, 'product_brand' );
+		$this->assertIsArray( $term, 'The product brand fixture should be created.' );
+
+		wp_set_object_terms( $product->get_id(), array( (int) $term['term_id'] ), 'product_brand' );
+
+		return get_term( (int) $term['term_id'], 'product_brand' )->slug;
+	}
+
+	/**
+	 * Run a main product query and capture the resulting WHERE clause alongside the returned posts.
+	 *
+	 * @param array $query_vars Query vars to add to the product query.
+	 * @return array {
+	 *     @type string     $0 The final WHERE clause.
+	 *     @type \WP_Post[] $1 The posts returned by the query.
+	 * }
+	 */
+	private function query_main_products( array $query_vars ): array {
+		$where         = '';
+		$capture_where = function ( array $clauses ) use ( &$where ): array {
+			$where = $clauses['where'];
+			return $clauses;
+		};
+		add_filter( 'posts_clauses', $capture_where, 20 );
+
+		global $wp_the_query;
+		$previous_wp_the_query = $wp_the_query;
+
+		try {
+			$query        = new \WP_Query();
+			$wp_the_query = $query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			$posts        = $query->query(
+				array_merge(
+					array(
+						'post_type' => 'product',
+						// Stands in for what WC_Query::pre_get_posts() sets on a product archive.
+						'wc_query'  => 'product_query',
+					),
+					$query_vars
+				)
+			);
+		} finally {
+			$wp_the_query = $previous_wp_the_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			remove_filter( 'posts_clauses', $capture_where, 20 );
+		}
+
+		return array( $where, $posts );
+	}
+
 	/**
 	 * @testdox Price clauses adjust for standard tax class when shop displays prices including tax.
 	 */