1

Problem specification

I'm implementing a website that is meant to host podcast shownotes and transcripts, and so I want custom permalinks and shortlinks for the webpages:

  • Permalinks of the form /podcasts/<episode_number>/<episode_title>, e.g. /podcasts/12/news-for-august
  • Shortlinks of the form /<episode_number> that redirect to the permalink, e.g. /12 redirects to /podcasts/12/news-for-august.

Ideally, partial or "incorrect" URLs that unambiguously match the permalink structure should redirect to the corresponding podcast permalink, e.g. the following should redirect to /podcasts/12/news-for-august:

  • /podcasts/12
  • /podcasts/12/incorrect-title

We use a custom post type for podcasts, created using CPT UI. The value of <episode_number> is stored in an ACF field with meta key episode_number; if multiple published podcasts have the same episode number (which obviously shouldn't happen in practice), then the one with the lowest post ID is served. The value of <episode_title> is just the post slug, since the podcast post's title holds the actual episode title (e.g. News for August).

As a (perhaps important?) sidenote, I'm using Nginx, not Apache — I see a lot of mention of .htaccess modification, which obviously doesn't apply here, so just mentioning that.

Progress so far

I've read up on the Rewrite API, and here's where I've got to after a couple days trying to understand the relevant inner workings...

When CPT UI registers the custom post type podcast, it also adds a permastruct with the name podcast. Since my permastruct for posts (set in [Settings > Permalinks > Custom Structure]) is /articles/%post_id%/%postname%, the podcast permastruct is /articles/podcast/%postname%. In CPT UI, I can disable "With front" (so that the leading /articles is dropped) and set "Rewrite slug" to podcasts (plural) rather than the default podcast (singular), which results in permalinks of the form /podcasts/<episode_title> instead. If I can't achieve what I want, I'll probably have to settle for this.

Rewrite tag

I define a rewrite tag, %podcast_episode_number%, so that I can define my custom permastruct for podcasts. I just override the permastruct with the name podcast that CPT UI added, so it automatically applies to podcasts. I also define a rewrite rule to handle the shortlinks. Here is the relevant section from my theme's functions.php:

function wpse373987_add_tag_and_permastruct() {
    /** Define the tag */
    add_rewrite_tag( '%podcast_episode_number%', '([0-9]+)' );

    /** Override the default permastruct for the podcast post type */
    add_permastruct(
        'podcast',
        'podcasts/%podcast_episode_number%/%postname%',
        [ 'with_front' => false ]
    );

    /** Define podcast shortlinks */
    add_rewrite_rule( '^([0-9]+)/?', [ 'podcast_episode_number' => '$matches[1]' ], 'top' );
}
add_action( 'init', 'wpse373987_add_tag_and_permastruct' );

Tag substitution

I then define how %podcast_episode_number% should be populated in permalinks by hooking into the post_link and post_type_link filters. Strangely, in the context of podcast permalinks, the %postname% tag isn't being populated like it is for regular blog posts, so I do that here as well:

function wpse373987_handle_tag_substitution( $permalink, $post ) {
    // Do nothing if the tag isn't present
    if ( strpos( $permalink, '%podcast_episode_number%' ) === false ) {
        return $permalink;
    }
    
    $fallback = '_';
    
    $episode_number = '';
    if ( function_exists( 'get_field' ) && $post->post_type === 'podcast' ) {
        $episode_number = get_field( 'episode_number', $post->ID, true );
    }
    if ( ! $episode_number ) {
        $episode_number = $fallback;
    }

    $permalink = str_replace( '%podcast_episode_number%', $episode_number, $permalink );
    $permalink = str_replace( '%postname%', $post->post_name, $permalink ); // Strangely, this is needed.

    return $permalink;
}
/**
 * Filter permalinks using `wpse373987_handle_tag_substitution()`. Late priority (100) is
 * assigned so that this filter is called last, when the tags are present.
 */
add_filter( 'post_link', 'wpse373987_handle_tag_substitution', 100, 2 );
add_filter( 'post_type_link', 'wpse373987_handle_tag_substitution', 100, 2 );

Query filter

Finally, I define how the query variable podcast_episode_number (which corresponds to the tag %podcast_episode_number%, and is implicitly registered when add_rewrite_tag() is called) should be handled, so that when we visit one of the URLs described in our problem specification, WordPress can use the podcast_episode_number parameter to determine the corresponding post ID, and thus serve the post. We hook into the request filter to do this.

function wpse373987_handle_query_var( $query_vars ) {
    /** Ignore requests that don't concern us. */
    if ( ! isset( $query_vars['podcast_episode_number'] ) ) {
        return $query_vars;
    }

    /** Validate the episode number; it must be a positive integer. */
    if ( preg_match( '/^[0-9]+$/', $query_vars['podcast_episode_number'] ) !== 1 ) {
        /**
         * The episode number is invalid; respond with a 404 Not Found.
         * We do this by requesting the post that has ID -1,
         * which is guaranteed to not exist.
         */
        return [ 'p' => '-1' ];
    }

    /** Casting to `int` removes leading zeroes from the SQL query */
    $episode_number = (int)( $query_vars['podcast_episode_number'] );

    /** Determine the ID of the post with the given episode number. */
    global $wpdb;

    $post_ids = $wpdb->get_col(
        $wpdb->prepare(
            "SELECT post_id FROM {$wpdb->postmeta} WHERE
                    meta_key = 'episode_number'
                AND meta_value = %d
            ORDER BY post_id ASC",
            
            $episode_number
        )
    );

    /**
     * String representing `$post_ids` in SQL syntax,
     * e.g. "('12','14','15','18')".
     */
    $sql_post_ids = "('" . implode( "','", $post_ids ) . "')";

    $post_ids = $wpdb->get_col(
        "SELECT id FROM {$wpdb->posts} WHERE
                id IN {$sql_post_ids}
            AND post_type = 'podcast'
            AND post_status = 'publish'
        ORDER BY id ASC"
    );

    if ( count( $post_ids ) === 0 ) {
        /**
         * There are no published podcasts with the given episode number;
         * respond with 404.
         */
        return [ 'p' => '-1' ];
    }

    /**
     * Request the post with the lowest post ID among published
     * podcasts with the given episode number.
     */
    return [ 'p' => $post_ids[0] ];
}
/**
 * Filter queries using `wpse373987_handle_query_var()`.
 * Late priority (100) is assigned to ensure that this filter is applied last.
 */
add_filter( 'request', 'wpse373987_handle_query_var', 100 );

After all that, and flushing the rewrite rules via [Settings > Permalinks > Save Settings], the link structures work! That is, for example, the web server responds to requests for all of the following URLs with a 301 redirect to /podcasts/12/news-for-august:

  • /12
  • /podcasts/12
  • /podcasts/12/incorrect-title

However, the page itself (/podcasts/12/news-for-august) cannot be found by WordPress... WordPress serves my theme's 404 template (404.php) and the HTTP response is 404, just like any other Not Found URL. Clearly, this is because WordPress doesn't know what template to use. I can resolve this by returning the post type as well as the post ID in handle_query_var() (i.e. return [ 'p' => $post_ids[0], 'post_type' => 'podcast' ]), but this has the undesirable effect of making all of the alias URLs listed above also just serve the content rather than redirecting to the permalink — this is obviously horrendous for SEO.

Question

So what gives? How can I get the correct template to load when the client visits a podcast permalink, without other URLs serving the same content? Perhaps hook into template_redirect or use wp_redirect() or something else? Maybe my overall approach here is wrong and someone can point me in the right direction?

Any advice is much appreciated.

0

After a lot of inspection of rewrites happening under the hood by hooking into filters and logging variable values, I managed to solve the problem!

Queries and query rewriting

When a query occurs, WordPress will serve the content using the correct template as soon as it has enough info to unambiguously determine what the template and post are. For the non-custom post type, WordPress just needs to know the post slug. For custom post types, it needs to know both the post slug and the post type; so for a podcast, the query needs to specify post_type=podcast and e.g. name=news-for-august. This is because post slugs are unique for a given post type, but needn't be unique across post types, so the slug itself doesn't suffice to identify the post. Also, the post type must be known in order for the right template to be selected. Thus, a request like /?post_type=podcast&name=news-for-august can be resolved and renders the post correctly.

Additionally, when a post type is registered, a rewrite tag and a query variable are registered that allow this querying to be condensed. For example, for my podcast post type, the rewrite tag is %podcast% (not %postname% like it is for non-custom posts), and the query var is podcast=; this acts as an abbreviated form of post_type plus name. For example, the request /?podcast=news-for-august is internally rewritten to /?podcast=news-for-august&post_type=podcast&name=news-for-august, and thus results in that post being served.

This explains the following problem:

Strangely, in the context of podcast permalinks, the %postname% tag isn't being populated like it is for regular blog posts.

Also, regarding the following...

When CPT UI registers the custom post type podcast, it also adds a permastruct with the name podcast. Since my permastruct for posts (set in [Settings > Permalinks > Custom Structure]) is /articles/%post_id%/%postname%, the podcast permastruct is /articles/podcast/%postname%.

... the default permalink structure is actually /articles/podcast/%podcast%.

When a post ID is specified in a query (via p=), it takes precedence over any post_type and/or name variable; if those variables don't agree with the specified ID, a redirect occurs. Indeed, it seems that a redirect always occurs if an ID is specified, e.g. if the ID of the podcast post News for August is 50, then /?p=50 is also internally rewritten to /?post_type=podcast&name=news-for-august, which results in a redirect to the permalink for that post.

We can take advantage of this behaviour to ensure that a redirect to the permalink occurs for the other URL formats that we want to implement.

Adjusting the permastruct and tag substitution

We'll adjust the permastruct to use %podcast% rather than %postname%:

function wpse373987_add_tag_and_permastruct() {
    /** Define the tag */
    add_rewrite_tag( '%podcast_episode_number%', '([0-9]+)' );

    /** Override the default permastruct for the podcast post type */
    add_permastruct(
        'podcast',
        'podcasts/%podcast_episode_number%/%podcast%',   // This line changed
        [ 'with_front' => false ]
    );

    /** Define podcast shortlinks */
    add_rewrite_rule( '^([0-9]+)/?', [ 'podcast_episode_number' => '$matches[1]' ], 'top' );
}
add_action( 'init', 'wpse373987_add_tag_and_permastruct' );

Since we are no longer using the %postname% tag in our permastruct, we also no longer need to substitute %postname% for the slug; this is done correctly with the %podcast% tag automatically. Filtering on post_link is also not necessary, since post_type_link is what is used for custom post types:

function wpse373987_handle_tag_substitution( $permalink, $post ) {
    // Do nothing if the tag isn't present
    if ( strpos( $permalink, '%podcast_episode_number%' ) === false ) {
        return $permalink;
    }
    
    $fallback = '_';
    
    $episode_number = '';
    if ( function_exists( 'get_field' ) && $post->post_type === 'podcast' ) {
        $episode_number = get_field( 'episode_number', $post->ID, true );
    }
    if ( ! $episode_number ) {
        $episode_number = $fallback;
    }

    $permalink = str_replace( '%podcast_episode_number%', $episode_number, $permalink );
    
    // The following line is now not needed.
    // $permalink = str_replace( '%postname%', $post->post_name, $permalink );

    return $permalink;
}
add_filter( 'post_type_link', 'wpse373987_handle_tag_substitution', 100, 2 );
// The following line is not needed.
// add_filter( 'post_link', 'wpse373987_handle_tag_substitution', 100, 2 );

Adjusting our query rewriting

After making the above two adjustments, the permalink of podcasts is of the form /podcasts/<episode_number>/<episode_title>, and content is served correctly from that URL, because it internally resolves to the query /?post_type=podcast&name=<episode_title>&podcast_episode_number=<episode_number>, which contains the post_type and name variables needed to determine which post to serve and which template to use.

However, for the other URL formats, i.e.:

  • /podcasts/<episode_number>/<incorrect_title>;
  • /podcasts/<episode_number>; and
  • /<episode_number>;

we still need to define how to resolve <episode_number> to a podcast, which we do by hooking into the request filter. Previously, we were rewriting all queries for podcasts to the form /?p=<podcast_post_id>, including when we visit the permalink URL, which is what was causing the 404 errors. This is because WordPress does not issue a redirect for a query of that form if the client is visiting the permalink URL — instead, the query processing continues, and WordPress just gives up with a 404 once it realises that the query doesn't contain post_type and name (since our query rewrite removed those) and it therefore can't determine which post to serve, nor which template to use.

Therefore, we should only rewrite queries to the form /?p=<podcast_post_id> when the URL we are currently visiting is not the permalink. The content is already already served correctly at the permalink URL; we just want to redirect other URLs to the permalink, which we can do by rewriting the query to just have the post ID, as we were before, but not when the client visits the permalink URL itself.

Also, rather than returning [ 'p' => '-1' ] to cause a 404 response when we need to, the proper way to do this is to return [ 'error' => 404 ].

Here is the modified filter:

function wpse373987_handle_query_var( $query_vars ) {
    /** Ignore requests that don't concern us. */
    if ( ! isset( $query_vars['podcast_episode_number'] ) ) {
        return $query_vars;
    }

    /** Validate the episode number; it must be an unsigned integer. */
    if ( preg_match( '/^[0-9]+$/', $query_vars['podcast_episode_number'] ) !== 1 ) {
        /** The episode number is invalid; respond with a 404 Not Found. */
        return [ 'error' => 404 ];
    }

    /**
     * Episode number, with any leading zeroes stripped;
     * they must be stripped for the SQL query to work.
     */
    $episode_number = (int)( $query_vars['podcast_episode_number'] );

    global $wpdb;
    
    /** Array of IDs of posts that have the given episode number */
    $post_ids = $wpdb->get_col(
        $wpdb->prepare(
            "SELECT post_id FROM {$wpdb->postmeta} WHERE
                    meta_key = 'episode_number'
                AND meta_value = %d
            ORDER BY post_id ASC",
            
            $episode_number
        )
    );

    /** String representing `$post_ids` in SQL syntax */
    $sql_post_ids = "('" . implode( "','", $post_ids ) . "')";

    // The logic after this point has been adjusted.

    /**
     * Determine the ID and name of the published podcast with the given episode
     * number (and lowest ID, if multiple such podcasts exist).
     */
    $podcast = $wpdb->get_row(
        "SELECT id, post_name AS name FROM {$wpdb->posts} WHERE
                id IN {$sql_post_ids}
            AND post_type = 'podcast'
            AND post_status = 'publish'
        ORDER BY id ASC"
    );

    /**
     * If there are no published podcasts with the given episode number,
     * respond with 404.
     */
    if ( $podcast === null ) {
        return [ 'error' => 404 ];
    }

    /**
     * If the podcast name specified in the query doesn't correspond to the
     * episode number specified in the query, we need to redirect to the right
     * page, based on the episode number (ignoring the specified name). We do
     * this by issuing a query for the post ID; that query will then redirect
     * to the podcast's permalink, where we won't take action.
     * 
     * Else, the specified name matches the specified episode number,
     * so we are already at the podcast's permalink, and thus do nothing.
     */
    if (    ! isset( $query_vars['name'] )
        ||  $query_vars['name'] !== $podcast->name
    ) {
        return [ 'p' => $podcast->id ];
    }

    return $query_vars;
}
add_filter( 'request', 'wpse373987_handle_query_var', 100 );

Result

Great, it works!

URLs of the form /podcasts/<episode_number>, followed by an incorrect slug or no slug, will redirect to the permalink of the podcast with that episode number. Shortlinks are now also correctly handled by the rewrite rule that we added in add_tag_and_permastruct(); it resolves URLs of the form /<episode_number> to a query of the form /?podcast_episode_number=<episode_number>. Queries of this form are already handled by our request filter, handle_query_var(), rewriting them to the form /?p=<post_id>, which WordPress then redirects to the corresponding podcast permalink. All sorted!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.