Creating a clean, logical URL structure is essential for both user experience and SEO. While WordPress handles hierarchical URLs for standard Pages and Categories out of the box, things get complicated when you introduce Custom Post Types (CPTs) and Hierarchical Custom Taxonomies.

If you have ever tried to achieve a URL structure like /products/hardware/tools/hammer/, where 'hardware' and 'tools' are levels of a custom taxonomy and 'hammer' is the CPT post name, you have likely encountered the dreaded 404 error. By default, WordPress doesn't know how to parse these nested relationships for custom entities.

In this guide, we will walk through a proven developer-community solution to bridge this gap, ensuring your site has professional, SEO-friendly hierarchical URLs.

Step 1: Register Your Custom Post Type and Taxonomy

The first step is to ensure your CPT and Taxonomy are registered with the correct rewrite arguments. The key here is using a placeholder in the CPT slug that we will later replace with the actual taxonomy trail.

In your functions.php or a custom plugin, set up your registration code like this:

// Register Custom Taxonomy
register_taxonomy('taxonomy_name', array('custom_post_type_name'), array(
    'hierarchical' => true,
    'query_var'    => true,
    'rewrite'      => array( 
        'slug'         => 'basename', 
        'with_front'   => true, 
        'hierarchical' => true 
    ),
));

// Register Custom Post Type
register_post_type('custom_post_type_name', array(
    'public'      => true,
    'has_archive' => true,
    'query_var'   => true,
    'rewrite'     => array(
        'slug'       => 'basename/%taxonomy_name%',
        'with_front' => true
    ),
    'supports'    => array('title', 'editor', 'thumbnail'),
));

Note: Setting 'hierarchical' => true in the taxonomy rewrite array is what allows the taxonomy archives themselves to have nested URLs (e.g., /basename/parent/child/).

Step 2: Dynamically Generating the Post Link

Since we used %taxonomy_name% as a placeholder in our CPT registration, WordPress won't know what to do with it automatically. We need to use the post_type_link filter to swap that placeholder with the full hierarchical path of the terms assigned to the post.

Add this to your functions.php:

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

    if ($terms = get_the_terms($post->ID, 'taxonomy_name'))
    {
        // We take the last term assigned and build the parent trail
        $term = array_pop($terms);
        $taxonomy_trail = get_taxonomy_parents($term->term_id, 'taxonomy_name', false, '/', true);
        $link = str_replace('%taxonomy_name%', $taxonomy_trail, $link);
    }
    return $link;
}
add_filter('post_type_link', 'filter_post_type_link', 10, 2);

Creating a Helper for Taxonomy Parents

While WordPress provides get_category_parents(), it is specifically hardcoded for default categories. To handle custom taxonomies, we need a custom helper function:

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

    $name = ($nicename) ? $parent->slug : $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);
    }

    $chain .= $name . $separator;    
    return $chain;    
}

Step 3: Resolving the 404 Issue

Even with the links appearing correctly on your site, clicking them will likely lead to a 404 page. This is because WordPress's rewrite engine doesn't recognize the long URL as a single post. You have two main ways to solve this.

Method A: Hardcoded Rewrite Rules (Regex)

If your URL structure has a fixed number of levels, you can use rewrite_rules_array. This is fast but less flexible if your nesting depth varies.

add_filter('rewrite_rules_array', 'mmp_rewrite_rules');
function mmp_rewrite_rules($rules) {
    $newRules  = array();
    // Adjust the number of (.+) segments based on your expected depth
    $newRules['basename/(.+)/(.+)/(.+)/(.+)/?$'] = 'index.php?custom_post_type_name=$matches[4]';
    $newRules['basename/(.+)/?$']                = 'index.php?taxonomy_name=$matches[1]'; 

    return array_merge($newRules, $rules);
}

For a more robust solution that handles any depth of nesting, use the request filter. This approach intercepts the query and checks if the last part of the URL matches a valid post slug.

function vk_query_vars($qvars){
    if(is_admin()) return $qvars;

    $custom_taxonomy = 'taxonomy_name';
    $custom_post_type = 'custom_post_type_name';

    if(array_key_exists($custom_taxonomy, $qvars)){
        $pathParts = explode('/', $qvars[$custom_taxonomy]);
        $lastPart = array_pop($pathParts);

        // Check if the last segment is actually a post
        $post = get_page_by_path($lastPart, OBJECT, $custom_post_type);

        if( $post && !is_wp_error($post) ){
            $qvars['p'] = $post->ID;
            $qvars['post_type'] = $custom_post_type;
            // Clear the taxonomy var so it doesn't try to load an archive
            unset($qvars[$custom_taxonomy]);
        }
    }
    return $qvars;
}
add_filter('request', 'vk_query_vars');

Common Mistakes to Avoid

  1. Forgetting to Flush Permalinks: Any time you change rewrite rules or registration arguments, you must go to Settings > Permalinks and click "Save Changes" to clear the cache.
  2. Slug Collisions: If you have a Page with the same slug as your basename, WordPress may get confused. Ensure your CPT base and Page slugs are unique or handled specifically in your rewrite rules.
  3. Ambiguous Slugs: If a taxonomy term and a post share the same slug (e.g., a category named "Hammer" and a product named "Hammer"), the request filter approach might require additional logic to prioritize one over the other.

Frequently Asked Questions

Can I use this for WooCommerce products?

While WooCommerce has its own permalink settings, this logic can be adapted. However, WooCommerce usually handles nested categories natively if configured in the settings. This custom approach is best for custom-built CPT systems.

Does this affect site performance?

Using the request filter adds a small database query (get_page_by_path) to every request that matches your taxonomy structure. On high-traffic sites, ensure you have an object cache (like Redis or Memcached) active to mitigate any performance hit.

Why not use a plugin?

Plugins like "Custom Post Type Permalinks" exist and can handle this, but they often add significant overhead. Writing the code yourself gives you full control over the regex and prevents "plugin bloat."

Wrapping Up

Achieving a structure like /basename/parent-tax/child-tax/post-name/ requires a three-pronged approach: proper registration, dynamic link filtering, and custom request handling. By following the steps above, you can create a highly organized URL hierarchy that improves your site's SEO and makes navigation intuitive for your users.

Always remember to test your URLs at various nesting depths and verify that your breadcrumbs (if used) still function correctly with the new structure.