Custom Post type & Taxonomy URL structure

The question:

I have a custom post type products and a custom taxonomy ‘Product Categories’ with the slug products.

My goal: I’m trying to create a URL structure like so:

Product Post Type Archive: products/

Product Top-level Category Archives: products/product-category

Product Category Archives: products/product-category/product-sub-category

Product Page: products/product-category/product-sub-category/product-page

The problem:
Product pages seem to only want to use actual categories. So URL structure is looking something like products/uncategorized/product-page. Category archive pages won’t maintain the cpt base. So they turn out something like category/sub-category.

What I’ve tried: Innumerous google searches, quite a few snippets (the ones I remember I’ll include below), a few custom functions, and a few plugins. No avail.

I have the following code.

add_action( 'init', 'products_cpt', 0);
add_action( 'init', 'register_taxonomies', 0 );

//A custom post type for all products
function products_cpt(){
    $labels = array(
        'name'               => _x('Products', 'Post Type General Name'),
        'singular_name'      => _x('Product', 'Post Type Singluar Name'),
        'menu_name'          => __('Products'),
        'parent_item_colon'  => __('Parent Product'),
        'all_items'          => __('All Products'),
        'view_item'          => __('View Product'),
        'add_new_item'       => __('Add New Product'),
        'add_new'            => __('Add New'),
        'edit_item'          => __('Edit Product'),
        'update_item'        => __('Update Product'),
        'search_items'       => __('Search Products'),
        'not_found'          => __('Not Found'),
        'not_found_in_trash' => __('Not Found in Trash')
    );
    $supports = array( 
        'title', 
        'editor', 
        'excerpt', 
        'thumbnail', 
        'custom-fields', 
        'revisions' 
    );
    $args = array(
        'labels'              => $labels,
        'hierarchical'        => true,
        'public'              => true,
        'exclude_from_search' => false,
        'show_in_admin_bar'   => true,
        'show_in_nav_menus'   => true,
        'publicly_queryable'  => true,
        'query_var'           => true,
        'taxonomies'          => array( 'chemicals' ),
        'supports'            => $supports,
        'has_archive'         => 'products'
    );
    register_post_type('products', $args);
}

function register_taxonomies() {
    $taxonomies = array(
        'products' => array(
            'Product',
            'Products'
        ),
    );
    foreach($taxonomies as $slug => $name){
        create_product_taxonomy($slug,$name[0],$name[1]);
    }
}

function create_product_taxonomy($slug, $singular, $plural) {
    $labels = array(
        'name'                       => _x( $singular.' Categories', 'Taxonomy General Name', 'text_domain' ),
        'singular_name'              => _x( $singular.' Category', 'Taxonomy Singular Name', 'text_domain' ),
        'menu_name'                  => __( $singular.' Categories', 'text_domain' ),
        'all_items'                  => __( 'All '.$singular.' Categories', 'text_domain' ),
        'parent_item'                => __( 'Parent '.$singular.' Category', 'text_domain' ),
        'parent_item_colon'          => __( 'Parent '.$singular.' Category:', 'text_domain' ),
        'new_item_name'              => __( 'New '.$singular.' Category Name', 'text_domain' ),
        'add_new_item'               => __( 'Add New '.$singular.' Category', 'text_domain' ),
        'edit_item'                  => __( 'Edit '.$singular.' Category', 'text_domain' ),
        'update_item'                => __( 'Update '.$singular.' Category', 'text_domain' ),
        'view_item'                  => __( 'View '.$singular.' Category', 'text_domain' ),
        'separate_items_with_commas' => __( 'Separate '.$singular.' Categories with commas', 'text_domain' ),
        'add_or_remove_items'        => __( 'Add or remove '.$singular.' Categories', 'text_domain' ),
        'choose_from_most_used'      => __( 'Choose from the most used '.$singular.' Categories', 'text_domain' ),
        'popular_items'              => __( 'Popular '.$singular.' Categories', 'text_domain' ),
        'search_items'               => __( 'Search '.$singular.' Categories', 'text_domain' ),
        'not_found'                  => __( 'Not Found', 'text_domain' ),
        'no_terms'                   => __( 'No '.$singular.' Categories', 'text_domain' ),
        'items_list'                 => __( $singular.' Categories list', 'text_domain' ),
        'items_list_navigation'      => __( $singular.' Categories list navigation', 'text_domain' ),
    );
     $args = array(
        'labels'                     => $labels,
        'hierarchical'               => true,
        'public'                     => true,
        'show_ui'                    => true,
        'show_admin_column'          => true,
        'show_in_nav_menus'          => true,
        'show_tagcloud'              => true,
        'has_archive'                => $plural,
        'rewrite' => array( 
            'slug'         => 'products',
            'with_front'   => true,
            'hierarchical' => true
        )
    );
    register_taxonomy( $slug, 'products', $args );
}

First things first, I set the permalinks setting to the following:
Permalink Settings

Next, I tried adding the following code, however it only worked for product pages. Product Category Archives still 404’d.

add_filter( 'post_type_link', 'products_post_link', 1, 3 );
function products_post_link( $post_link, $id = 0 ){
    $post = get_post($id);  
    if ( is_object( $post ) ){
        $terms = wp_get_object_terms( $post->ID, 'products_category' );
        $slug_url = '';
        $last_id = 0;
        if( $terms ){
            foreach($terms as $term) {
                if ($term === reset($terms)){
                    foreach($terms as $termInner){
                        if($termInner->term_id == 0){
                            $slug_url .= $termInner->slug.'/';
                        }
                    }
                }elseif ($term === end($terms)){
                    foreach($terms as $termInner){
                        if($termInner->parent == $last_id){
                            $slug_url .= $termInner->slug;
                            $last_id = $termInner->term_id;
                        }
                    }
                }else{
                    foreach($terms as $termInner){
                        if($termInner->parent == $last_id){
                            $slug_url .= $termInner->slug.'/';
                            $last_id = $termInner->term_id;
                        }
                    }
                }
            }
            return str_replace( '%category%' , $slug_url , $post_link );
        }
    }
    return $post_link;  
}

And this to the CPT init function:

    'rewrite' => array(
        'slug'         => 'products/%category%',
        'with_front'   => false,
        'hierarchical' => true,
    ),

I also tried the following, don’t remember what happened but I remember it didn’t work:

add_action('init', 'custom_resource_rewrite_rules');
function custom_resource_rewrite_rules() {
    add_rewrite_rule('products/([A-Za-z0-9-_]+)/?', '$matches[1]', 'top');
}

I also tried playing with the term_link filter. No bueno.

Lastly, I tried the following Plugins, also no luck.

Custom Permalinks

Permalinks Customizer

Custom Post Type Permalinks

Does anyone have a solution to this? I’m pulling my hair out.

The Solutions:

Below are the methods you can try. The first solution is probably the best. Try others if the first one doesn’t work. Senior developers aren’t just copying/pasting – they read the methods carefully & apply them wisely to each case.

Method 1

After forever, I figured out an answer!

First: we register the custom post type & custom taxonomy:

add_action( 'init', 'register_sps_products_post_type' );
function register_sps_products_post_type() {
    register_post_type( 'sps-product',
        array(
            'labels' => array(
                'name' => 'Products',
                'menu_name' => 'Product Manager',
                'singular_name' => 'Product',
                'all_items' => 'All Products'
            ),
            'public' => true,
            'publicly_queryable' => true,
            'show_ui' => true,
            'show_in_menu' => true,
            'show_in_nav_menus' => true,
            'supports' => array( 'title', 'editor', 'author', 'thumbnail', 'comments', 'post-formats', 'revisions' ),
            'hierarchical' => false,
            'has_archive' => 'products',
            'taxonomies' => array('product-category'),
            'rewrite' => array( 'slug' => 'products/%product_category%', 'hierarchical' => true, 'with_front' => false )
        )
    );
    register_taxonomy( 'product-category', array( 'sps-product' ),
        array(
            'labels' => array(
                'name' => 'Product Categories',
                'menu_name' => 'Product Categories',
                'singular_name' => 'Product Category',
                'all_items' => 'All Categories'
            ),
            'public' => true,
            'hierarchical' => true,
            'show_ui' => true,
            'rewrite' => array( 'slug' => 'products', 'hierarchical' => true, 'with_front' => false ),
        )
    );
}

Next, we add a new rewrite rule so WordPress knows how to interpret our new permalink structure:

add_action( 'generate_rewrite_rules', 'register_product_rewrite_rules' );
function register_product_rewrite_rules( $wp_rewrite ) {
    $new_rules = array( 
        'products/([^/]+)/?$' => 'index.php?product-category=' . $wp_rewrite->preg_index( 1 ), // 'products/any-character/'
        'products/([^/]+)/([^/]+)/?$' => 'index.php?post_type=sps-product&product-category=' . $wp_rewrite->preg_index( 1 ) . '&sps-product=' . $wp_rewrite->preg_index( 2 ), // 'products/any-character/post-slug/'
        'products/([^/]+)/([^/]+)/page/(d{1,})/?$' => 'index.php?post_type=sps-product&product-category=' . $wp_rewrite->preg_index( 1 ) . '&paged=' . $wp_rewrite->preg_index( 3 ), // match paginated results for a sub-category archive
        'products/([^/]+)/([^/]+)/([^/]+)/?$' => 'index.php?post_type=sps-product&product-category=' . $wp_rewrite->preg_index( 2 ) . '&sps-product=' . $wp_rewrite->preg_index( 3 ), // 'products/any-character/sub-category/post-slug/'
        'products/([^/]+)/([^/]+)/([^/]+)/([^/]+)/?$' => 'index.php?post_type=sps-product&product-category=' . $wp_rewrite->preg_index( 3 ) . '&sps-product=' . $wp_rewrite->preg_index( 4 ), // 'products/any-character/sub-category/sub-sub-category/post-slug/'
    );
    $wp_rewrite->rules = $new_rules + $wp_rewrite->rules;
}

The next function on the other hand fixes the issue with the sub-categories. The issue itself consists in the fact, that when you try to load a page with URL faq/category/child-category/, WordPress would try to load a post with slug “child-category” instead of the sub-category with slug “child-category”.

// A hacky way of adding support for flexible custom permalinks
// There is one case in which the rewrite rules from register_kb_rewrite_rules() fail:
// When you visit the archive page for a child section(for example: http://example.com/faq/category/child-category)
// The deal is that in this situation, the URL is parsed as a Knowledgebase post with slug "child-category" from the "category" section
function fix_product_subcategory_query($query) {
    if ( isset( $query['post_type'] ) && 'sps-product' == $query['post_type'] ) {
        if ( isset( $query['sps-product'] ) && $query['sps-product'] && isset( $query['product-category'] ) && $query['product-category'] ) {
            $query_old = $query;
            // Check if this is a paginated result(like search results)
            if ( 'page' == $query['product-category'] ) {
                $query['paged'] = $query['name'];
                unset( $query['product-category'], $query['name'], $query['sps-product'] );
            }
            // Make it easier on the DB
            $query['fields'] = 'ids';
            $query['posts_per_page'] = 1;
            // See if we have results or not
            $_query = new WP_Query( $query );
            if ( ! $_query->posts ) {
                $query = array( 'product-category' => $query['sps-product'] );
                if ( isset( $query_old['product-category'] ) && 'page' == $query_old['product-category'] ) {
                    $query['paged'] = $query_old['name'];
                }
            }
        }
    }
    return $query;
}
add_filter( 'request', 'fix_product_subcategory_query', 10 );

This function lets WordPress know how to handle %product_category% in your custom post type rewrite slug structure:

function filter_post_type_link($link, $post)
{
    if ($post->post_type != 'sps-product')
        return $link;

    if ($cats = get_the_terms($post->ID, 'product-category'))
    {
        $link = str_replace('%product_category%', get_taxonomy_parents(array_pop($cats)->term_id, 'product-category', false, '/', true), $link); // see custom function defined below
        $link = str_replace('//', '/', $link);
        $link = str_replace('http:/', 'http://', $link);
    }
    return $link;
}
add_filter('post_type_link', 'filter_post_type_link', 10, 2);

A custom function based off of get_category_parents. It gets the taxonomy’s parents:

// my own function to do what get_category_parents does for other taxonomies
function get_taxonomy_parents($id, $taxonomy, $link = false, $separator = '/', $nicename = false, $visited = array()) {    
    $chain = '';   
    $parent = &get_term($id, $taxonomy);

    if (is_wp_error($parent)) {
        return $parent;
    }

    if ($nicename)    
        $name = $parent -> slug;        
else    
        $name = $parent -> name;

    if ($parent -> parent && ($parent -> parent != $parent -> term_id) && !in_array($parent -> parent, $visited)) {    
        $visited[] = $parent -> parent;    
        $chain .= get_taxonomy_parents($parent -> parent, $taxonomy, $link, $separator, $nicename, $visited);

    }

    if ($link) {
        // nothing, can't get this working :(
    } else    
        $chain .= $name . $separator;    
    return $chain;    
}

Sources:

Method 2

Actually, this is a horrible way of doing this.

If you add the taxonomy before the custom post type using the timing of the add_action, you’ll be able to use the custom post type rewrite rule to prefix the taxonomy.

ex.

    
    // Register Custom Taxonomy
    function type_taxonomy() {
    
        $labels = array(
            'name'                       => _x( 'Types', 'Taxonomy General Name', 'asw' ),
            'singular_name'              => _x( 'Type', 'Taxonomy Singular Name', 'asw' ),
        );
        $rewrite = array(
            'slug'                       => 'update/type',
            'with_front'                 => false,
            'hierarchical'               => false,
        );
        $args = array(
            'labels'                     => $labels,
            'hierarchical'               => true,
            'public'                     => true,
            'show_ui'                    => true,
            'show_admin_column'          => true,
            'show_in_nav_menus'          => true,
            'show_tagcloud'              => true,
            'rewrite'                    => $rewrite,
            'show_in_rest'               => true,
        );
        register_taxonomy( 'type', array( 'updates' ), $args );
    
    }
// Set the timing before the post type
    add_action( 'init', 'type_taxonomy', $timing = 0 );
    
    }
    
    if ( ! function_exists('updates_post_type') ) {
    
    // Register Custom Post Type
    function updates_post_type() {
    
        $labels = array(
            'name'                  => _x( 'Updates', 'Post Type General Name', 'asw' ),
            'singular_name'         => _x( 'Updates', 'Post Type Singular Name', 'asw' ),
        );
        $rewrite = array(
            'slug'                  => 'update',
            'with_front'            => true,
            'pages'                 => true,
            'feeds'                 => true,
        );
        $args = array(
            'label'                 => __( 'Updates', 'asw' ),
            'description'           => __( 'Posts should include important updates on health, well being and community', 'asw' ),
            'labels'                => $labels,
            'supports'              => array( 'title', 'editor', 'thumbnail' ),
            'taxonomies'            => array( 'type', 'category', 'post_tag', 'brands', 'markets' ),
            'hierarchical'          => false,
            'public'                => true,
            'show_ui'               => true,
            'show_in_menu'          => true,
            'menu_position'         => 5,
            'menu_icon'             => 'dashicons-heart',
            'show_in_admin_bar'     => true,
            'show_in_nav_menus'     => true,
            'can_export'            => true,
            'has_archive'           => true,
            'exclude_from_search'   => false,
            'publicly_queryable'    => true,
            'rewrite'               => $rewrite,
            'capability_type'       => 'page',
            'show_in_rest'          => true,
        );
        register_post_type( 'updates', $args );
    
    }
// Set the timing after the taxonomy 
    add_action( 'init', 'updates_post_type', $timing = 10 );
    
    }


All methods was sourced from stackoverflow.com or stackexchange.com, is licensed under cc by-sa 2.5, cc by-sa 3.0 and cc by-sa 4.0

Leave a Comment