I'm creating quite complex website for a literary magazine. For its custom post types Issue and Event, I wanted different permalink structure than for posts (which is basic http://example.com/%postname%).

My task wasn't the easy one and I've managed to come up with a solution, even though it is a quite twisted one. As I'm not sure if that's ideal, I would love to hear opinions of WP users, who understand the dark heart of internal $wp_query better than I do. If WP Stackexchange is not the appropriate place for a code review, please refer me somewhere else.

I needed to achieve the following:

  1. URLs reflect the dates relevant to CPTs - such as http://example.com/event/%eventyear%/%eventmonth%/%postname% and http://example.com/event/%year%/%issue-num%
  2. The last part of URL can be duplicated - such as http://example.com/issue/2016/5 and http://example.com/issue/2015/5
  3. CPT have archives - archive of 2012 issues will be available on http://example.com/issue/2012.
  4. The last part of URL should be generated from something else than the title (meta value perhaps).

First, I've registered my CPT:

$cpt_args = array(
  'label' => 'Issue',
  'hierarchical' => false,
  'has_archive' => true,
  'rewrite' => array('slug' => 'issue/%issueyear%'),

Then, I've registered my rewrite tags:

add_action('init', function() use ($tags) {

Then, I've registered my "rewrite_tag translation" of `%issueyear% to custom meta value:

$func = function($permalink, $post) {

  if (strpos($permalink, "%issueyear%")) {
    $tags_used[] = $t;

  $issue_start = get_post_meta($post->ID, 't_issue_start', true);

  $old = basename($permalink);
  $new = $this->date->get_time('Y', $issue_start);
  $permalink = str_replace($old, $new, $permalink);

  return $permalink;


add_action('post_link', $func, 10, 2);
add_action('post_type_link', $func, 10, 2);

Then, I've added my rewrite rules, passing my meta value to URL.

add_action('init', function() {

add_action('query_vars', function($vars) {
    $vars[] = 'cibulka_key';
    $vars[] = 'cibulka_val';
    return $vars;

URL http://example.com/2016/5 would give me archive.php template. So there, I included single template, if $wp_query has cibulka_key set. include_template_with_var is my function, allowing passing parameters to templates.

/** Archive.php */

global $wp_query;
if (isset($wp_query->query['cibulka_key'])) {
    $meta_value = $wp_query->query['cibulka_val'] . '_' . $wp_query->query['year'];
    $args = array(
        'post_type' => $wp_query->query['post_type'],
        'meta_key' => $wp_query->query['cibulka_key'],
        'meta_value' => $meta_value,
        'posts_per_page' => 1
    $posts = get_posts($args);
    if (!empty($posts)) {
        $data = array(
            'id' => $posts[0]->ID,
            'post' => $posts[0]
    } else {
        $data = array();
    include_template_with_var('single.php', $data);
} else {
    // Do normal archive stuff

Single template of post with $data['id'] is served instead of archive.php.

Note: This is not my actual code, I've simplified it a lot for the purpose of my question.

This achieves all 4 points, that I needed achieved, I'm just not sure if it's the right way to approach this though - performance-wise (there will be A LOT of events), standard-wise, etc. So before I stick with this sollution (as it will quite heavy consequences, as setting up URL scheme has), I would like to hear some oppinions.

So far, I've discovered those caveats:

  1. WP recognizes content on http://example.com/issue/5 as an archive, even though it should be single. So is_single(), is_single('issue') return false and the result of body_class() is confusing.

I will add them here as more of them appear.

Thanks a lot in advance!

Edit 1

I've removed conditionals from Archive.php and replaced it with filters. This solved caveat 1 and removed logic from my templates. Yay!

// Change global `wp_query` to retrieving the post by meta query AND mark it as single (not archive)
add_action('pre_get_posts', function() {
    global $wp_query;
    if (!isset($wp_query->query['cibulka_key'])) { return; }

    switch ($wp_query->query['post_type']) {
        case 'issue':
            $meta_value =  $wp_query->query['cibulka_val'] . '_' . $wp_query->query['year'];

    $wp_query->set('meta_key', $wp_query->query['cibulka_key']);
    $wp_query->set('meta_value', $meta_value);

    $wp_query->is_singular = true;
    $wp_query->is_single = true;
    $wp_query->is_archive = false;

    remove_all_actions ( '__after_loop');


// Use single.php rather than archive.php, if global `$wp_query` contains my custom properties
add_filter('template_include', function($template) {

    global $wp_query;
    if (!isset($wp_query->query['cibulka_key'])) { return $template; }

    $single_tmplt = locate_template('single.php');
    return $single_tmplt;


For some reason body_class() does not add single-issue CSS class this way, but that is nothing ...

add_filter('body_class', function($body_class) {
    if (is_single() && get_post_type() === 'issue') {
        $body_class[] = 'single-issue';
    return $body_class;
}, 11, 1);

... can't fix. :)

  • just on the body_class conditions, you are missing brackets, you should use if (is_single() && get_post_type() == 'issue') not is_single... or you could instead use just if (is_singular('issue')) {
    – majick
    Jun 12, 2016 at 11:38
  • @majick: Yes, that is correct - it was not literal code (as it was commented out). I'm really asking more whether querying posts by their meta values to simulate "duplicate slugs" is a good idea, than how to actually do it (as my current code works). Jun 12, 2016 at 19:02
  • no probs just didn't have time for a full answer at the time so thought I'd add that since you said it was broken... anyway, I have added one now.
    – majick
    Jun 13, 2016 at 4:34

1 Answer 1


Since it is a performance question, you might be able to avoid using meta keys at all by storing/getting that data another way and not having to set a separate meta field to match with...

a. You could get the year from the published $post->post_date... So that when doing the query just use the date argument:

$args = array(
    'post_type' => $wp_query->query['post_type'],
    'date_query' => array( array('year' => $wp_query->query['year']) ),
    'posts_per_page' => -1
$posts = get_posts($args);

b. You could set the issue number using the page attributes $post->menu_order field. This would have the added advantage of giving it's own post writing screen metabox (and even the quick edit on the post list screen) without any additional code, and sits well with the purpose of the field (ordering issues like you would pages.) Just add support to the post type when registering it, or you can also do:


...then following the code above you would have:

if (!empty($posts)) {
    foreach ($posts as $post) {
        if ($post->menu_order == $wp_query->query['cibulka_val']) {
            $data['id'] = $post->ID;

The advantage is both post_date and menu_order are both in the row of the posts table (and thus the $post object automatically too) so no need for SQL to access the postmeta table this way to match the data... though it is probably a pretty small gain if multipled by thousands who knows... you could still be getting hundreds of posts for that year and looping them this way.

So instead, you could use the menu_order and post_date as mentioned, but do you own custom query just to get the Post ID - which truly is a super-efficient way of doing it - there is really nothing faster here. eg:

if (isset($wp_query->query['cibulka_key'])) {
    global $wpdb;
    $query = "SELECT ID FROM ".$wpdb->prefix."posts 
              WHERE YEAR(post_date) = '".$wp_query->query['year']."' 
              AND menu_order = '".$wp_query->query['cibulka_val']."' 
              AND post_status = 'publish'";
    $postid = $wpdb->get_var($query);
    if ($postid) {
        $data['id'] = $postid;
        // if this is really needed here?
        $data['post'] = get_post($postid);
    } else {$data = array();}
    include_template_with_var('single.php', $data);

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

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