Rewrite rules for custom post type ‘attached’ to another custom post type

The way WordPress rewrite rules work (with a couple of special exceptions), different content types have some sort of unique element to their URLs that enables WP to identify what type of content it’s looking for in the database before it goes and looks for it. For example, given the URL:

/topic/one/two/three/

You can’t say with any certainty if three is a location or a topic if both locations and topics share the same topic root.

So that said, the complicated part of this is that you need to resolve that ambiguity manually. This has a downside- every request for what might potentially be a topic will require an extra query to the database, which isn’t a major issue, but something to be aware of.

In your original question you expressed the desire to remove the custom post type base. The answer I’ll give here uses topic as the base. Removing that throws the page post type into the whole mix. You may be able to work it out using this solution, but for the sake of just showing you the simplest form of how this works, I’m not going to do that.

Step 1

Add a new %parent_location% rewrite tag that will be the placeholder for a topic’s location. This happens on the init action along with your rules:

add_rewrite_tag( '%parent_location%', '(.+)' );

Step 2

Register your post types. I’ll leave out the basic stuff and focus on the specific parts that make this work. This all happens on the init action as well. Don’t forget to flush rewrite rules after adding these.

For locations, the root slug is topic, and the post type is hierarchical.

$args = array(
    'rewrite' => array( 'slug' => 'topic' ),
    'hierarchical' => true,
    // ... your other args
);
register_post_type( 'location', $args );

For topics, we put the new %parent_location% rewrite tag in the slug. We’ll use this to substitute the location in the URL. The rewrite rules this generates will never actually be matched, but it makes the next steps easier.

$args = array(
    'rewrite' => array( 'slug' => 'topic/%parent_location%' ),
    // ... your other args
);
register_post_type( 'topic', $args );

Step 3

Add a filter to post_type_link to swap the location path in for our %parent_location% tag whenever a permalink for this topic is requested. Have a look at the comments to see what’s happening.

function wpd_topic_link( $link, $post ) {
    // if this is a topic post
    if ( $post->post_type === 'topic' ) {
        // if there is location ID meta saved for this post under parent_location key
        if( $location_id = get_post_meta( $post->ID, 'parent_location', true ) ){
            // query for that post to make sure it exists
            $location_post = get_posts( array( 'post_type' => 'location', 'p' => $location_id ) );
            if( !empty( $location_post ) ){
                // get the location permalink
                // strip out everything except the location parts of the URL
                // substitute that value for our %parent_location% placeholder
                $location_link = get_permalink( $location_post[0] );
                $location_path = untrailingslashit( str_replace( home_url( '/topic/' ), '', $location_link ) );
                $link = str_replace( '%parent_location%', $location_path, $link );
            }
        }
    }
    return $link;
}
add_filter( 'post_type_link', 'wpd_topic_link', 20, 2 );

Now when you add a topic post and save a valid location ID in post meta, you will see that path reflected in the URL when editing that topic post.

Step 4

Filter request to look for any requests for a location that might actually be a topic. Read through the comments to understand what’s happening. Another side-effect to note here- you can never have a location slug that’s also a topic.

function wpd_locations_may_be_topics( $request ){
    // if the location query var is set
    if( isset( $request['location'] ) ){
        // location will be a parent / child hierarchy
        // make it an array of URL segments
        $parts = explode( "https://wordpress.stackexchange.com/", $request['location'] );
        // it might be a topic only if there's more than a single segment
        if( count( $parts ) > 1 ){
            $topic_slug = end( $parts );
            $maybe_topic = get_posts( array( 'post_type' => 'topic', 'name' => $topic_slug ) );
            // if a topic was returned
            if( !empty( $maybe_topic ) ){
                // change request from location to topic    
                unset( $request['location'] );
                $request['post_type'] = 'topic';
                $request['topic'] = $topic_slug;
            }
        }
    }
    return $request;
}
add_filter( 'request', 'wpd_locations_may_be_topics' );

Leave a Comment