Search code examples
phpwordpresswoocommercehook-woocommercebreadcrumbs

Breadcrumbs on product page don't reflect current url/slug when using multiple categories for product


My Wordpress site uses Woocommerce for products to sell. But when you assign multiple product categories to a product and visit the product page (with Product Permalinks set to Shop base with category), something unexpected is happening. The breadcrumb link to the (parent) category is always the same one and does not reflect the navigated url. (This is the same problem as described here.)

Problem in summary:

Navigate to Breadcrumb link Expected breadcrumb link
/shop/category-1/sample-product/ category-2 category-1
/shop/category-2/sample-product/ category-2 category-2

I found no real answer on any source that fixes this. (I think it should be fixed, but some might say it's for SEO reasons, preventing duplicate site contents.) So to help anyone searching for the answer, here it is:


Solution

  • What's going (wr)on(g)?

    The breadcrumbs on the product page are produced by the woocommerce_breadcrumb() function. Which in turn gets it through WC_Breadcrumb->generate().

    Traversing further in the code, you end up inside the add_crumbs_single() function where the woocommerce_breadcrumb_product_terms_args filter is applied to the ordering of the product terms fetch. (See wp_get_post_terms and WP_Term_Query::__construct() for more info on this.)

    It's clear from the code, that they prioritize the term with the highest parent value, in other words, the term with the latest added parent in the database.

    Solution 1

    Since this will always be the same for this product, you might want to add a filter to your theme's function.php (or using a custom plugin) that will overwrite this behavior. I got mine working using this:

    function prioritize_current_url_cat_in_breadcrumbs( $args ) {
        // Only if we're on a product page..
        if ( is_product() ) {
    
            // ..and there's a product category slug in the navigated url..
            $product_cat_slug = get_query_var( 'product_cat', '' );
            if ( ! empty($product_cat_slug) ) {
    
                // ..which we can find
                $product_cat = get_term_by( 'slug', $product_cat_slug, 'product_cat', ARRAY_A );
                if ( ! empty($product_cat) ) {
    
                    // Then only get that current product category to start the breadcrumb trail
                    $args['term_taxonomy_id'] = $product_cat['term_taxonomy_id'];
                }
            }
        }
        return $args;
    }
    add_filter( 'woocommerce_breadcrumb_product_terms_args', 'prioritize_current_url_cat_in_breadcrumbs' );
    

    The rest of the add_crumbs_single() function will take care of traversing the category's parents etc up 'till Home.

    Solution 2

    Alternatively, you could use the woocommerce_breadcrumb_main_term filter to change the 'main term' used for the breadcrumb trail. The filter function receives 2 arguments: the main term (as WP_Term) and an array of WP_Terms with all product categories found. And it returns one term, so you can search through the array and pick the right one you want to start the breadcrumbs with.

    function prioritize_current_url_cat( $first_term, $all_terms ) {
        if ( is_product() ) {
            $product_cat_slug = get_query_var( 'product_cat', '' );
            if ( ! empty($product_cat_slug) ) {
                // Get the WP_Term with the current slug
                $filtered_terms = array_values( array_filter($all_terms, function($v) use ($product_cat_slug) {
                    return $v->slug === $product_cat_slug;
                }) );
                if ( ! empty($filtered_terms) ) {
                    return $filtered_terms[0];
                }
            }
        }
        // Fallback to default
        return $first_term;
    }
    add_filter( 'woocommerce_breadcrumb_main_term', 'prioritize_current_url_cat', 10, 2 );
    

    Hope this helps anyone searching for hours and working through lines of source code..!

    Extra: Fix permalinks on archive pages as well

    For tackling the same problem on the product archive pages, you can hook into the post_type_link filter and pre-emptively replace the %product_cat% part of the permalink with the correct slug like so:

    /**
     * Set product link slugs to match the page context,
     * for products with multiple associated categories.
     *
     * For example:
     *   when viewing Category A, use '/category-a/this-product/'
     *   when viewing Category B, use '/category-b/this-product/'
     */
    function my_plugin_product_url_use_current_cat( $post_link, $post, $leavename, $sample ) {
        // Get current term slug (used in page url)
        $current_product_term_slug = get_query_var( 'product_cat', '' );
        if ( empty ( $current_product_term_slug ) ) {
            return $post_link;
        }
    
        if ( is_product_category() ) {
            // Get current term object
            $current_product_term = get_term_by( 'slug', $current_product_term_slug, 'product_cat' );
            if ( FALSE === $current_product_term ) {
                return $post_link;
            }
    
            // Get all terms associated with product
            $all_product_terms = get_the_terms( $post->ID, 'product_cat' );
    
            // Filter terms, taking only relevant terms for current term
            $matching_or_descendant_terms = array_filter( array_map( function( $term ) use ( $current_product_term ) {
                // Return term if it is the current term
                if ( $term->term_id === $current_product_term->term_id ) {
                    return [ TRUE, $term ];
                }
    
                // Return term if one of its ancestors is the current term (highest hierarchy first)
                $parent_terms = array_reverse( get_ancestors( $term->term_id, 'product_cat' ) );
                foreach ( $parent_terms as $parent_term_id ) {
                    if ( $parent_term_id === $current_product_term->term_id ) {
                        return [ FALSE, $term ];
                    }
                }
    
                // Leave out all others
                return NULL;
            }, $all_product_terms ) );
    
            if ( count( $matching_or_descendant_terms ) > 0 ) {
                // Sort terms (directly associated first, descendants next)
                usort( $matching_or_descendant_terms, function( $a, $b ) {
                    if ( $a[0] === $b[0] ) {
                        return 0;
                    } else if ( TRUE === $a[0] ) {
                        return -1;
                    } else {
                        return 1;
                    }
                } );
    
                // Get entire slug (including ancestors)
                $slug = get_term_parents_list( $matching_or_descendant_terms[0][1]->term_id, 'product_cat', [
                    'format' => 'slug',
                    'separator' => '/',
                    'link' => false,
                    'inclusive' => true,
                ] );
    
                // Set url slug to closest child term of current term
                // or current term (if directly associated)
                $post_link = str_replace('%product_cat%/', $slug, $post_link);
            }
        } else if ( is_product() ) {
            $post_link = str_replace('%product_cat%', $current_product_term_slug, $post_link);
        }
        return $post_link;
    }
    add_filter( 'post_type_link', 'my_plugin_product_url_use_current_cat', 10, 4 );
    

    Explanation

    As @Markos mentioned, we tried to figure out the most logical and intuitive way to do this.

    Basically, it checks if the current product category (slug from the url) is directly associated with the displayed product. If so, use that slug for the link to the product page. (Using one of the solutions above, the breadcrumbs reflect the url path for intuitive navigation.)

    If the current viewed product category is not directly associated with the product, it looks for the first matching descendant (sub) category and uses that slug for the link to the product page.

    This way, the user always sees how they came on the current product page and can easily navigate back to the category.

    Note: If this snippet doesn't change anything, try an earlier hook priority and/or check whether the $post_link variable contains the %product_cat% template part.