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.
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 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.
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 . "https://wordpress.stackexchange.com/" . $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 . "https://wordpress.stackexchange.com/" . $book->slug . "https://wordpress.stackexchange.com/" . $post->post_name ) );
}
}
break;
}
return $post_link;
}
Learn more about the CPT-onomies plugin