Removing base slug from hierarchical custom post type

I have a hierarchical custom post type called “state”. My goal is to have a permalink structure like thus:

I’m close but haven’t quite figured it out. Here’s the code I’m using:

First, I remove the slug

public function remove_slugs( $permalink, $post, $leavename ) { 

      $url_components = parse_url( $permalink );
      $post_path = $url_components['path'];
      $post_name = end( explode( '/', trim( $post_path, '/' ) ) );

      if( !empty( $post_name )) {

        switch($post->post_type) {

          case 'state':

            if( $post->post_parent ) {

                $parent = get_post( $post->post_parent );
                $parent = $parent->post_name;

                $permalink = str_replace( $post_path, '/' . $parent . '/' . $post_name . '/', $permalink );

            } else { 

                $permalink = str_replace( $post_path, '/' . $post_name . '/', $permalink );




      return $permalink;


This is hooked into post_type_link.

Next, I reset the query variables so WordPress knows we’re dealing with a CPT

    public function parse_custom_post_type( $query ) {

        if ( ! $query->is_main_query() ) return;
        if ( count( $query->query ) != 2 || ! isset( $query->query['page'] ) ) return;

        // Are we dealing with a page?
        if ( ! empty( $query->query['pagename'] ) && ! is_home() ) {

            // If the page doesn't exist, we must be dealing with a state
            if ( ! is_page( $query->query['pagename'] ) ) {
                $query->set( 'name', $query->query['pagename'] );
                $query->set( 'state', $query->query['pagename'] );
                $query->set( 'post_type', 'state' );
                $query->is_page = 0;
                $query->is_single = 1;
                unset( $query->query_vars['page'] );
                unset( $query->query_vars['pagename'] );
                unset( $query->query['page'] );
                unset( $query->query['pagename'] );


This is hooked into it pre_get_posts.

So, the level one pages work, but not the subpages. The URLs resolve and then hit a 404.

What do I need to do to get this working?



Two things before I begin:

  1. You really shouldn’t do this. Having static anchors to rewrite rules is there for efficiency. Your best bet is to find a slug that you’re happy with — maybe instead of “state” use “activities” (or whatever your sub-pages are).
  2. Most likely, your sub-pages are not actually states (since you wouldn’t have /idaho/vermont/), so this data structure doesn’t seem to make much sense. You might consider having “state” be one data structure and “sub-page” be another. If state is a classification, you could make it a taxonomy. If state is more than that, it could be a post type. Here are instructions for setting up rewrites for the former from a WordCamp talk that I gave in Portland, and here are instructions for the latter from another question on this site.

The Code

If you decide to move forward with this structure, here is a singleton class that should work out of the box for you if your post type has ‘hierarchical’ set to true and ‘rewrite’ set to false. Be sure to flush your rewrites after adding this to your theme’s functions.php file or your plugin (go to Settings → Permalinks and click “Save Changes”).

 * Strip the slug out of a hierarchical custom post type

if ( !class_exists( 'State_Rewrites' ) ) :

class State_Rewrites {

    private static $instance;

    public $rules;

    private function __construct() {
        /* Don't do anything, needs to be initialized via instance() method */

    public static function instance() {
        if ( ! isset( self::$instance ) ) {
            self::$instance = new State_Rewrites;
        return self::$instance;

    public function setup() {
        add_action( 'init',                array( $this, 'add_rewrites' ),            20 );
        add_filter( 'request',             array( $this, 'check_rewrite_conflicts' )     );
        add_filter( 'state_rewrite_rules', array( $this, 'strip_state_rules' )           );
        add_filter( 'rewrite_rules_array', array( $this, 'inject_state_rules' )          );

    public function add_rewrites() {
        add_rewrite_tag( "%state%", '(.+?)', "state=" );
        add_permastruct( 'state', "%state%", array(
            'ep_mask' => EP_PERMALINK
        ) );

    public function check_rewrite_conflicts( $qv ) {
        if ( isset( $qv['state'] ) ) {
            if ( get_page_by_path( $qv['state'] ) ) {
                $qv = array( 'pagename' => $qv['state'] );
        return $qv;

    public function strip_state_rules( $rules ) {
        $this->rules = $rules;
        # We no longer need the attachment rules, so strip them out
        foreach ( $this->rules as $regex => $value ) {
            if ( strpos( $value, 'attachment' ) )
                unset( $this->rules[ $regex ] );
        return array();

    public function inject_state_rules( $rules ) {
        # This is the first 'page' rule
        $offset = array_search( '(.?.+?)/trackback/?$', array_keys( $rules ) );
        $page_rules = array_slice( $rules, $offset, null, true );
        $other_rules = array_slice( $rules, 0, $offset, true );
        return array_merge( $other_rules, $this->rules, $page_rules );



I thought this topic was interesting and I wrote up a deep explanation of this on my blog. There, I included some slightly more complex code that wasn’t as relevant to your specific question.

  • Matthew – Thank so much for your time and the detailed response! I implemented it and it worked beautifully..
  • @JonathanWold No problem! Just realized I left my post type setup in the code, which I added for testing. I’m going to edit the answer and remove that (so as not to confuse others who come across this). Just an FYI so you can update your copy.
  • I’m circling back to this because I discovered that this logic doesn’t allow /blog/page/2/ to work (it breaks the query). Would you be up for helping me investigate it further? Also, I noticed that your updated code was missing "add_permastruct( 'state', "%state%", array( 'with_front' => false ) );"
  • Curious… why does this need to be a singleton?

You may also like...