Nested custom post types with permalinks

I am trying to set up a multi-level custom post type structure with permalinks that look like authors/books/chapters, with authors, books, and chapters all set up as their own custom post type. For example, a typical URL on this site might look like example.com/authors/stephen-king/the-shining/chapter-3/

Each chapter can only belong to one book, and each book can only belong to one author. I’ve considered using taxonomies instead of CPTs for authors and books, but I need to associate metadata with each item and I prefer the post interface for this.

Read More

I’m most of the way there by simply setting up each custom post as a child of an entry in the CPT one level up. For example, I create “Chapter 3” and assign “The Shining” as a parent using a custom meta-box. “The Shining” in turn has “Stephen King” as a parent. I haven’t had any trouble creating these relationships.

I’m using rewrite tags in the CPT slugs and the permalinks want to work, but they’re not quite right. Using a re-write analyzer, I can see that the rewrite rules are actually generated, but they don’t seem to be in the right order and so other rules are processed first.

Here's a screenshot of my rewrite analyzer.

Here’s how I’ve registered my CPTs:

function cpt_init() {

  $labels = array(
    'name' => 'Authors'
   );

  $args = array(
    'labels' => $labels,
    'public' => true,
    'publicly_queryable' => true,
    'show_ui' => true, 
    'show_in_menu' => true, 
    'query_var' => true,
    'rewrite' => array(
        'slug' => 'author',
        'with_front' => FALSE,
    ),
    'with_front' => false,
    'capability_type' => 'post',
    'has_archive' => false, 
    'hierarchical' => true,
    'menu_position' => null,
    'supports' => array( 'title', 'editor' )
  ); 

  register_post_type('authors',$args);

  $labels = array(
    'name' => 'Books'
  );

  $args = array(
    'labels' => $labels,
    'public' => true,
    'publicly_queryable' => true,
    'show_ui' => true, 
    'show_in_menu' => true, 
    'query_var' => true,
    'rewrite' => array(
        'slug' => 'author/%authors%',
        'with_front' => FALSE,
    ),
    'with_front' => false,
    'capability_type' => 'post',
    'has_archive' => false, 
    'hierarchical' => true,
    'menu_position' => null,
    'supports' => array( 'title', 'editor' )
  ); 

  register_post_type('books',$args);


  $labels = array(
    'name' => 'Chapters'
   );

  $args = array(
    'labels' => $labels,
    'public' => true,
    'publicly_queryable' => true,
    'show_ui' => true, 
    'show_in_menu' => true, 
    'query_var' => true,
    'rewrite' => array(
        'slug' => 'author/%authors%/%books%',
        'with_front' => FALSE,
    ),
    'with_front' => FALSE,
    'capability_type' => 'post',
    'has_archive' => false, 
    'hierarchical' => true,
    'menu_position' => null,
    'supports' => array( 'title', 'editor' )
  ); 

  register_post_type('chapters',$args);

}

add_action( 'init', 'cpt_init' );

So is there any way to change the priority of my rewrite rules so that authors, books, and chapters are all matched first?

I also know that I’m going to have to add a post_type_link filter, but that seems secondary to getting the permalinks right in the first place. If anyone knows where I can find a comprehensive overview of how that filter works, it would be appreciated.

Related posts

Leave a Reply

3 comments

  1. If you want to keep ‘authors’ as the base slug in the permalinks, i.e. example.com/authors/stephen-king/ for the ‘authors’ CPT, example.com/authors/stephen-king/the-shining/ for the ‘books’ CPT and example.com/authors/stephen-king/the-shining/chapter-3/ for the ‘chapters’ CPT, WordPress will think pretty much everything is an ‘authors’ post or a hierarchical child of an ‘authors’ post and, since that is not the case, WordPress ultimately becomes very confused.

    With that said, there’s a workaround that is quite basic but as long as your permalink structure always follows the same order, i.e. the word ‘authors’ is always followed by an author slug, which is always followed by a book slug which is always followed by a chapter slug, then you should be good to go.

    In this solution, there’s no need to define the rewrite slug in the custom post type definition for ‘chapters’ and ‘books’, but set the ‘authors’ rewrite slug as simply ‘authors’, place the following code in your functions.php file and “flush” your rewrite rules.

    add_action( 'init', 'my_website_add_rewrite_tag' );
    function my_website_add_rewrite_tag() {
        // defines the rewrite structure for 'chapters', needs to go first because the structure is longer
        // says that if the URL matches this rule, then it should display the 'chapters' post whose post name matches the last slug set
        add_rewrite_rule( '^authors/([^/]*)/([^/]*)/([^/]*)/?','index.php?chapters=$matches[3]','top' );
        // defines the rewrite structure for 'books'
        // says that if the URL matches this rule, then it should display the 'books' post whose post name matches the last slug set
        add_rewrite_rule( '^authors/([^/]*)/([^/]*)/?','index.php?books=$matches[2]','top' );   
    }
    
    // this filter runs whenever WordPress requests a post permalink, i.e. get_permalink(), etc.
    // we will return our custom permalink for 'books' and 'chapters'. 'authors' is already good to go since we defined its rewrite slug in the CPT definition.
    add_filter( 'post_type_link', 'my_website_filter_post_type_link', 1, 4 );
    function my_website_filter_post_type_link( $post_link, $post, $leavename, $sample ) {
        switch( $post->post_type ) {
    
            case 'books':
    
                // I spoke with Dalton and he is using the CPT-onomies plugin to relate his custom post types so for this example, we are retrieving CPT-onomy information. this code can obviously be tweaked with whatever it takes to retrieve the desired information.
                // we need to find the author the book belongs to. using array_shift() makes sure only one author is allowed
                if ( $author = array_shift( wp_get_object_terms( $post->ID, 'authors' ) ) ) {
                    if ( isset( $author->slug ) ) {
                        // create the new permalink
                        $post_link = home_url( user_trailingslashit( 'authors/' . $author->slug . '/' . $post->post_name ) );
                    }
                }
    
                break;
    
            case 'chapters':
    
                // I spoke with Dalton and he is using the CPT-onomies plugin to relate his custom post types so for this example, we are retrieving CPT-onomy information. this code can obviously be tweaked with whatever it takes to retrieve the desired information.
                // we need to find the book it belongs to. using array_shift() makes sure only one book is allowed
                if ( $book = array_shift( wp_get_object_terms( $post->ID, 'books' ) ) ) {
    
                    // now to find the author the book belongs to. using array_shift() makes sure only one author is allowed
                    $author = array_shift( wp_get_object_terms( $book->term_id, 'authors' ) );
    
                    if ( isset( $book->slug ) && $author && isset( $author->slug ) ) {
                        // create the new permalink
                        $post_link = home_url( user_trailingslashit( 'authors/' . $author->slug . '/' . $book->slug . '/' . $post->post_name ) );
                    }
    
                }
    
                break;
    
        }
        return $post_link;
    }
    

    Learn more about the CPT-onomies plugin

  2. The rules will get added to the extra_rules_top of WP_Rewrite in the order that the extra permastructs are added. So, switching the order that you register the post types will switch the order of the rewrite rules get generated making the chapter rewrite get matched first. However, since you’re using the query_var from the other post_types the wp_query may end up matching one of those as the queried post name before matching the chapter like you want.

    I would create new rewrite tags to represent the placeholders for the parent author and parent-book, ie:

    add_rewrite_tag('%parent-book%', '([^/]+)', 'parent_book=');
    

    When doing this, you’ll have to filter ‘query_vars’ to make ‘parent_book’ public. Then you’ll need to add a filter to pre_get_posts that will convert the name set as the parent_book query_var into the post_id and set it as the ‘post_parent’.