Commit ee6866e42e for woocommerce
commit ee6866e42eac7adb29ef941ee1490269ebfc2ee1
Author: Michael Pretty <prettyboymp@users.noreply.github.com>
Date: Mon Feb 9 09:01:20 2026 -0500
Skip category processing in product permalinks when not needed (#63121)
* Skip category processing in product permalinks when not needed
When the product permalink structure doesn't contain %category% or
%product_cat% placeholders, skip the expensive category term processing
that fetches terms and calculates the deepest category.
This avoids unnecessary database queries (get_the_terms, get_ancestors)
and filter executions for sites using permalink structures like
/product/%product%/ or /product/%post_id%/.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add missing docblock for woocommerce_product_post_type_link_parent_category_only filter
Fixes PHPCS CommentHooks.MissingHookComment error.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/performance-WOOPLUG-6202-skip-category-processing b/plugins/woocommerce/changelog/performance-WOOPLUG-6202-skip-category-processing
new file mode 100644
index 0000000000..4e21d5bcf4
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-WOOPLUG-6202-skip-category-processing
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Skip category term processing in product permalinks when structure doesn't include category placeholders
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index a70f2d3b47..3677172030 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -268,64 +268,81 @@ function wc_product_post_type_link( $permalink, $post ) {
return $permalink;
}
- // Get the custom taxonomy terms in use by this post.
- $terms = get_the_terms( $post->ID, 'product_cat' );
+ // Only process category if the permalink structure uses category placeholders.
+ $needs_category = strpos( $permalink, '%category%' ) !== false || strpos( $permalink, '%product_cat%' ) !== false;
+ $product_cat = '';
- if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
- // Find the deepest category (most ancestors) for the permalink.
- $deepest_term = $terms[0];
- $deepest_ancestors = $deepest_term->parent ? get_ancestors( $deepest_term->term_id, 'product_cat' ) : array();
+ if ( $needs_category ) {
+ // Get the custom taxonomy terms in use by this post.
+ $terms = get_the_terms( $post->ID, 'product_cat' );
- foreach ( $terms as $term ) {
- if ( $term->term_id === $deepest_term->term_id ) {
- continue;
- }
- // Skip root categories - they can't be deeper than current.
- if ( ! $term->parent ) {
- continue;
- }
- $ancestors = get_ancestors( $term->term_id, 'product_cat' );
- if ( count( $ancestors ) > count( $deepest_ancestors ) ) {
- $deepest_ancestors = $ancestors;
- $deepest_term = $term;
+ if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
+ // Find the deepest category (most ancestors) for the permalink.
+ $deepest_term = $terms[0];
+ $deepest_ancestors = $deepest_term->parent ? get_ancestors( $deepest_term->term_id, 'product_cat' ) : array();
+
+ foreach ( $terms as $term ) {
+ if ( $term->term_id === $deepest_term->term_id ) {
+ continue;
+ }
+ // Skip root categories - they can't be deeper than current.
+ if ( ! $term->parent ) {
+ continue;
+ }
+ $ancestors = get_ancestors( $term->term_id, 'product_cat' );
+ if ( count( $ancestors ) > count( $deepest_ancestors ) ) {
+ $deepest_ancestors = $ancestors;
+ $deepest_term = $term;
+ }
}
- }
- /**
- * Filter the product category used for the product permalink.
- *
- * By default, the deepest category (most ancestors) is selected. Prior to 9.9.0,
- * categories were sorted by parent term ID descending, then term ID ascending.
- * This filter allows customization of which category is used in the product permalink.
- *
- * @since 2.4.0
- * @since 9.9.0 Selection algorithm changed to use deepest category instead of sort order.
- *
- * @param WP_Term $deepest_term The selected category term object (deepest category since 9.9.0).
- * @param WP_Term[] $terms All category terms assigned to the product.
- * @param WP_Post $post The product post object.
- */
- $category_object = apply_filters( 'wc_product_post_type_link_product_cat', $deepest_term, $terms, $post );
- $category_object = ! $category_object instanceof WP_Term ? $deepest_term : $category_object;
- $product_cat = $category_object->slug;
-
- if ( $category_object->parent ) {
- // Reuse cached ancestors if the filter didn't change the category, otherwise fetch them.
- $ancestors = ( $category_object->term_id === $deepest_term->term_id )
- ? $deepest_ancestors
- : get_ancestors( $category_object->term_id, 'product_cat' );
- foreach ( $ancestors as $ancestor ) {
- $ancestor_object = get_term( $ancestor, 'product_cat' );
- if ( apply_filters( 'woocommerce_product_post_type_link_parent_category_only', false ) ) {
- $product_cat = $ancestor_object->slug;
- } else {
- $product_cat = $ancestor_object->slug . '/' . $product_cat;
+ /**
+ * Filter the product category used for the product permalink.
+ *
+ * By default, the deepest category (most ancestors) is selected. Prior to 9.9.0,
+ * categories were sorted by parent term ID descending, then term ID ascending.
+ * This filter allows customization of which category is used in the product permalink.
+ *
+ * @since 2.4.0
+ * @since 9.9.0 Selection algorithm changed to use deepest category instead of sort order.
+ *
+ * @param WP_Term $deepest_term The selected category term object (deepest category since 9.9.0).
+ * @param WP_Term[] $terms All category terms assigned to the product.
+ * @param WP_Post $post The product post object.
+ */
+ $category_object = apply_filters( 'wc_product_post_type_link_product_cat', $deepest_term, $terms, $post );
+ $category_object = ! $category_object instanceof WP_Term ? $deepest_term : $category_object;
+ $product_cat = $category_object->slug;
+
+ if ( $category_object->parent ) {
+ // Reuse cached ancestors if the filter didn't change the category, otherwise fetch them.
+ $ancestors = ( $category_object->term_id === $deepest_term->term_id )
+ ? $deepest_ancestors
+ : get_ancestors( $category_object->term_id, 'product_cat' );
+ foreach ( $ancestors as $ancestor ) {
+ $ancestor_object = get_term( $ancestor, 'product_cat' );
+
+ /**
+ * Filter whether to use only the top-level parent category in the product permalink.
+ *
+ * When true, only the top-level ancestor category slug is used instead of
+ * the full category hierarchy path (e.g., 'parent' instead of 'parent/child/grandchild').
+ *
+ * @since 2.6.5
+ *
+ * @param bool $use_parent_only Whether to use only the top-level parent category. Default false.
+ */
+ if ( apply_filters( 'woocommerce_product_post_type_link_parent_category_only', false ) ) {
+ $product_cat = $ancestor_object->slug;
+ } else {
+ $product_cat = $ancestor_object->slug . '/' . $product_cat;
+ }
}
}
+ } else {
+ // If no terms are assigned to this post, use a string instead (can't leave the placeholder there).
+ $product_cat = _x( 'uncategorized', 'slug', 'woocommerce' );
}
- } else {
- // If no terms are assigned to this post, use a string instead (can't leave the placeholder there).
- $product_cat = _x( 'uncategorized', 'slug', 'woocommerce' );
}
$find = array(
diff --git a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
index b93ac13ab8..50b2632103 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -556,4 +556,39 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
wp_delete_term( $root2_term['term_id'], 'product_cat' );
wp_delete_term( $root1_term['term_id'], 'product_cat' );
}
+
+ /**
+ * @testdox Product permalink skips category processing when permalink structure has no category placeholders.
+ */
+ public function test_wc_product_post_type_link_skips_category_when_not_in_permalink() {
+ // Create a category to ensure it's not fetched unnecessarily.
+ $category_term = wp_insert_term( 'Test Category', 'product_cat' );
+
+ $product = WC_Helper_Product::create_simple_product();
+ wp_set_object_terms( $product->get_id(), array( $category_term['term_id'] ), 'product_cat' );
+
+ $product_post = get_post( $product->get_id() );
+
+ // Test with %post_id% placeholder but no category placeholder.
+ $permalink = wc_product_post_type_link( '/product/%post_id%/' . $product_post->post_name . '/', $product_post );
+
+ $this->assertStringContainsString(
+ '/product/' . $product->get_id() . '/',
+ $permalink,
+ 'Permalink should have the post ID replaced'
+ );
+ $this->assertStringNotContainsString(
+ '%post_id%',
+ $permalink,
+ 'Permalink should not contain unreplaced %post_id% placeholder'
+ );
+ $this->assertStringNotContainsString(
+ '%category%',
+ $permalink,
+ 'Permalink should not contain %category% placeholder'
+ );
+
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $category_term['term_id'], 'product_cat' );
+ }
}