Shadow Posttype

The case I was dealing with. There is a posttype “person” and it had the normal templates, a archive-person.php and single-person.php with a bunch of metadata.
Which where visible on example.com/person/john-doe and the archive example.com/persons.
So far nothing special.

Every “person” was a -well- person. Some persons where also a “Judge”. A judge was an extra metabox on the person edit page.
And it needed it’s own pages on the frontend. example.com/judge/john-doe and example.com/judges.

Adding the rewrites

<?php
function judge_rewrite_rule() {
	/**
	 * Single judge
	 * 
	 * set is_judge to 1
	 * set person to post slug
	 */
	add_judge_rewrite_rule( '^judge/([^/]+)/?', 'index.php?is_judge=1&person=$matches[1]', 'top' );

	/**
	 * Archive for judges
	 */
	add_rewrite_rule( '^judges/?', 'index.php?is_judge=1&post_type=person', 'top' );
}
add_action( 'init','rewrite_rule' );

function judge_rewrite_tag() {
	/**
	 * make the query_vars aware of `is_judge`
	 */
	add_rewrite_tag( '%is_judge%', '([0-9]+)' );
}
add_action( 'init', 'judge_rewrite_tag' );

The main 2 functions to look out for are add_rewrite_rule and add_rewrite_tag.
I register the query_var is_judge and set it when the url is /judges/ or /judge/john-doe

After adding this do flush the permalinks. Just go to the settings for permalinks and press save. No need to change anything.

Registering the templates

<?php
/**
 * Set the correct theme/template-file.php
 * the shadow single & archive.
 *
 * @param string $template
 *
 * @return string
 */
function assign_judge_template( $template ) {
	if ( 1 !== (int) get_query_var( 'is_judge' ) ) {
		return $template; // we only check for templates when the judge set.
	}

	// assign the correct template.
	if ( is_single() ) {
		return get_template_directory() . '/single-person-judge.php';
	}
	if ( is_post_type_archive( 'person' ) ) {
		return get_template_directory() . '/archive-person-judge.php';
	}


	return $template; // fallback.
}
add_filter( 'template_include', 'assign_judge_template', 10, 1 );

When the is_judge is set set a different template. In this case this these templates are in the theme folder. You could also set templates from a plugin. The naming is can be anything but I picked single-person-judge.php so the next person can easier find the relation to the post type.

Setting the correct posts for the judge archive

Now the single will work as you would expect. But the archive page will still include all persons, not just the judges. Lets fix that.

<?php
/**
 * @param WP_Query $query
 */
function judge_pre_get_posts( $query ) {
	if ( 1 !== (int) $query->get( 'is_judge' ) || $query->is_single() ) {
		// only do this check if `is_judge` is set.
		// for single's this check is not needed.
		return;
	}
	// the meta_query
	$meta_is_judge = [
	    'relation' => 'AND',
		[ 'key'     => 'is_judge', 'compare' => 'EXISTS',],
		[
            'key'     => 'is_judge',
            'value'   => '1',
            'compare' => '=',
		],
	];

	// This part is to make sure you don't override other possible existing meta_queries
	$existing_meta   = $query->get( 'meta_query' );
	if ( empty( $existing_meta ) ) {
		$query->set( 'meta_query', $meta_is_judge );
	} else {
		$query->set( 'meta_query', [ 'relation' => 'AND', $existing_meta, $meta_is_judge, ] );
	}

}

add_action( 'pre_get_posts', 'judge_pre_get_posts' , 10, 1 );

If you’re familiar with the pre_get_posts hook this should not be to hard.
We select all posts that have the post_meta is_judge set to 1.
The way I add the meta_query seems a bit excessive, but this makes sure you never override a possible existing meta_query which an other filter might have added.

Now every Judge should be listed on example.com/judge and every individual judge should be visible on example.com/judge/john-doe. Only thing left to do is link to the page.

Linking to the judge url

As we want a person to be visible on both example.com/person/john-doe and example.com/judge/john-doe we can’t override the default permalink. So the best next thing is to create a helper function for calling the judge link.
This function should be used where you need to link to example.com/judge/john-doe, like on the judge archive page.

<?php
/**
 * Helper function
 *
 * @param int|WP_Post|null $post
 *
 * @return string, if not a judge, return normal permalink
 */
function get_judge_permalink( $post = null ) {
	$post = get_post( $post );
	if ( is_null( $post ) ) {
		return null;
	}

	// if the meta `is_judge` is not set, return the default person permalink.
	// for your use cases you might want to return something different.
	if ( '1' !== get_post_meta($post->ID, 'is_judge', true ) ) {
		return get_the_permalink( $post );
	}

	return home_url( 'judge/' . $post->post_name );
}

The function can be used in the same way as the regular get_the_permalink. get_judge_permalink(); the post argument is optional, and can also be a post_id.

Closing thoughts

In the template files, I suggest adding a very clear comment at the top, which explains where the rewrite functions are. An other person will otherwise have a hard time to find where this is created.

The list of judges will now automatically appear on the /judge/ archive page. If you need to create this list on a custom WP_query just add the query_var is_judge, example:

<?php
$q_args = array(  
	'post_type' => 'person',
	'is_judge' => 1, // this will get picked up by the `judge_pre_get_posts` filter
	// other arguments
);
$custom_query = new WP_Query($q_args);
// do what you want