We Are Communication Architects

Building brand awareness through content creation and community engagement.

November 30th, 2010

Adding Rewrite Rules for Custom Post Types

Since the addition of custom post types to WordPress in version 2.9, one of the questions I’ve seen most on message boards and forums has been from users trying to figure out how to have their custom post type have loop pages like the built in posts post type does. Initially, when the permalink handling for custom post types was added in version 3.0, it only handled the endpoint for the single post. It is now looking like WordPress 3.1 will have support for a root archive for custom permalinks, but still won’t support any of the permastructure tags. Because of these limitations, we have had to come up with ways to easily support a more robust rewrite handling for custom post types.

For this example, I’ll create a custom post type called ‘movie-review’ that will have a permalink structure of mysite.com/movie-reviews/%year%/%monthnum%/%day%/%review-title%/ with supporting rewrite rules at each of the directories within that structure, ie, mysite.com/movie-reviews/%year%/,mysite.com/movie-reviews/%year%/%monthnum%/, etc.

The code starts with the normal register_post_type() call, however the ‘rewrite’ argument will be set to false, so the default handling doesn’t interfere with the custom rewrite rules being added.

register_post_type('movie-review', array(
  'labels' => array(
    'name' => _x('Movie Reviews', 'post type general name'),
    'singular_name' => _x('Movie Review', 'post type singular name'),
    'add_new' => _x('Add New', 'movie-review'),
    'add_new_item' => __('Add New Movie Review'),
    'edit_item' => __('Edit Movie Review'),
    'new_item' => __('New Movie Review'),
    'view_item' => __('View Movie Review'),
    'search_items' => __('Search Movie Reviews'),
    'not_found' =>  __('No Movie Reviews found'),
    'not_found_in_trash' => __('No Movie Reviews found in Trash'),
    'parent_item_colon' => ''
  ),
  'public' => true,
  'publicly_queryable' => true,
  'query_var' => 'movie-review',
  'rewrite' => false,
  'hierarchical' => false,
  'supports' => array('title', 'editor', 'excerpt')
));

The next addition is a rewrite tag to use for the movie-review post type. This tag will work similar to the %postname% tag used when creating the posts permalink structure, except it will be used for the custom post type’s title.

global $wp_rewrite;
$wp_rewrite->add_rewrite_tag('%movie-review%', '([^/]+)', 'movie-review=');

Now that the custom rewrite tag has been created, the code to generate the rewrite rules for each endpoint that the custom post type will need can be added. The WP_Rewrite::generate_rewrite_rules() method that is used for normal post rewrite rule creation can be used to parse and create most of the rewrite rules the post type needs. They will just need some slight modifications. The permalink prefix, which is the base for the custom post type, and the permalink structure are defined separately to allow the prefix to be used to create the rewrite rules for the root of the post type.

global $wp_rewrite;

//the root of the post type, ie mysite.com/movie-reviews/ will be the landing page for the post type
$permalink_prefix = 'movie-reviews';
//the permalink structure for the post type that will be appended to the prefix, mysite.com/movie-reviews/2010/11/25/test-movie-review/
$permalink_structure = '%year%/%monthnum%/%day%/%movie-review%/';

//we use the WP_Rewrite class to generate all the endpoints WordPress can handle by default.
$rewrite_rules = $wp_rewrite->generate_rewrite_rules($permalink_prefix.'/'.$permalink_structure, EP_ALL, true, true, true, true, true);

//build a rewrite rule from just the prefix to be the base url for the post type
$rewrite_rules = array_merge($wp_rewrite->generate_rewrite_rules($permalink_prefix), $rewrite_rules);
$rewrite_rules[$permalink_prefix.'/?$'] = 'index.php?paged=1';
foreach($rewrite_rules as $regex => $redirect) {
  if(strpos($redirect, 'attachment=') === false) {
    //add the post_type to the rewrite rule
    $redirect .= '&post_type=movie-review';
  }

  //turn all of the $1, $2,... variables in the matching regex into $matches[] form
  if(0 < preg_match_all('@\$([0-9])@', $redirect, $matches)) {
    for($i = 0; $i < count($matches[0]); $i++) {
      $redirect = str_replace($matches[0][$i], '$matches['.$matches[1][$i].']', $redirect);
    }
  }
  //add the rewrite rule to wp_rewrite
  $wp_rewrite->add_rule($regex, $redirect, 'top');
}

Once all of the code to generate the rewrite rules is in place, the rewrite rules need to be flushed. This can either be done through the admin by re-saving the permalinks, or through code if included in a plugin. The rewrite rules are now finished. The only thing left is to override the permalink format for the links created for the post type with the following:

add_filter('post_type_link', 'filter_movie_review_link', 10, 2);
function filter_movie_review_link($permalink, $post) {
  if(('movie-review' == $post->post_type) && '' != $permalink && !in_array($post->post_status, array('draft', 'pending', 'auto-draft')) ) {
    $rewritecode = array(
      '%year%',
      '%monthnum%',
      '%day%',
      '%movie-review%'
    );

    $unixtime = strtotime($post->post_date);
    $date = explode(" ",date('Y m d H i s', $unixtime));
    $rewritereplace = array(
      $date[0],
      $date[1],
      $date[2],
      $post->post_name,
    );
    $permalink = str_replace($rewritecode, $rewritereplace, '/movie-reviews/%year%/%monthnum%/%day%/%movie-review%/');
    $permalink = user_trailingslashit(home_url($permalink));
  }
  return $permalink;
}

This should complete all the steps needed to add all of the rewrite rules for each of the endpoints within the permalink structure created for this post type. I went ahead and put this all together in a small plugin that wraps the register_post_type() method with the above code to add the needed rewrite rules:

class Custom_Post_Type_With_Rewrite_Rules {

  private $post_type;
  private $query_var;
  private $permalink_prefix;
  private $permalink_structure;

  /**
   * Constructor method
   *
   * $permalink_args options:
   * -front: The front of the permalinks for this post type.  All URLs for this post type will start with this
   * -structure: The structure of the permalink.  Accepts the following tags: %year%, %month%, %day% and %{query_var}%, the structure must contain the query var tag
   *
   * @param string $post_type
   * @param array $post_type_args Arguments normally passed into register_post_type
   * @param array $permalink_args Arguments controlling the permalink structure.
   */

  public function __construct($post_type, $post_type_args = array(), $permalink_args = array()) {
    //make sure the rewrite settings for the post type are set to false to prevent interference
    $post_type_args['rewrite'] = false;

    //register the post type and get the returned args
    $post_type_args = register_post_type($post_type, $post_type_args);

    if('' == get_option('permalink_structure') || !$post_type_args->publicly_queryable) {
      return; //only continue if using permalink structures and post type is publicly queryable
    }

    $this->post_type = $post_type_args->name;
    $this->query_var = $post_type_args->query_var;

    $default_permalink_args = array(
      'structure' => '%year%/%monthnum%/%day%/%'.$this->query_var.'%/',
      'front' => $this->post_type
    );

    $permalink_args = wp_parse_args($permalink_args, $default_permalink_args);

    $this->permalink_prefix = trim($permalink_args['front'], '/');
    $this->permalink_structure = trailingslashit(ltrim($permalink_args['structure'], '/'));

    //register the add_rewrite_rules method to run only when rules are being flushed.
    add_action('delete_option_rewrite_rules', array($this, 'add_rewrite_rules'));

    //go ahead and add the rewrite rules if the option is currently empty
    $current_rules = get_option('rewrite_rules');
    if(empty($current_rules)) {
      $this->add_rewrite_rules();
    }

    //add a filter to fix the url for this post type
    add_filter('post_type_link', array($this, 'filter_post_type_link'), 10, 4);

  }

  public function add_rewrite_rules() {
    global $wp_rewrite;

    //register the rewrite tag to use for the post type
    $wp_rewrite->add_rewrite_tag('%'.$this->query_var.'%', '([^/]+)', $this->query_var . '=');

    //we use the WP_Rewrite class to generate all the endpoints WordPress can handle by default.
    $rewrite_rules = $wp_rewrite->generate_rewrite_rules($this->permalink_prefix.'/'.$this->permalink_structure, EP_ALL, true, true, true, true, true);

    //build a rewrite rule from just the prefix to be the base url for the post type
    $rewrite_rules = array_merge($wp_rewrite->generate_rewrite_rules($this->permalink_prefix), $rewrite_rules);
    $rewrite_rules[$this->permalink_prefix.'/?$'] = 'index.php?paged=1';
    foreach($rewrite_rules as $regex => $redirect) {
      if(strpos($redirect, 'attachment=') === false) {
        //add the post_type to the rewrite rule
        $redirect .= '&post_type=' . $this->post_type;
      }

      //turn all of the $1, $2,... variables in the matching regex into $matches[] form
      if(0 < preg_match_all('@\$([0-9])@', $redirect, $matches)) {
        for($i = 0; $i < count($matches[0]); $i++) {
          $redirect = str_replace($matches[0][$i], '$matches['.$matches[1][$i].']', $redirect);
        }
      }
      //add the rewrite rule to wp_rewrite
      $wp_rewrite->add_rule($regex, $redirect, 'top');
    }
  }

  /**
   * Filter to turn the links for this post type into ones that match our permalink structure
   *
   * @param string $permalink
   * @param object $post
   * @return string New permalink
   */

  public function filter_post_type_link($permalink, $post) {
    if(($this->post_type == $post->post_type) && '' != $permalink && !in_array($post->post_status, array('draft', 'pending', 'auto-draft')) ) {
      $rewritecode = array(
        '%year%',
        '%monthnum%',
        '%day%',
        '%hour%',
        '%minute%',
        '%second%',
        '%post_id%',
        '%author%',
        '%'.$this->query_var.'%'
      );

      $author = '';
      if ( strpos($this->permalink_structure, '%author%') !== false ) {
        $authordata = get_userdata($post->post_author);
        $author = $authordata->user_nicename;
      }

      $unixtime = strtotime($post->post_date);
      $date = explode(" ",date('Y m d H i s', $unixtime));
      $rewritereplace = array(
        $date[0],
        $date[1],
        $date[2],
        $date[3],
        $date[4],
        $date[5],
        $post->ID,
        $author,
        $post->post_name,
      );
      $permalink = str_replace($rewritecode, $rewritereplace, '/'.$this->permalink_prefix.'/'.$this->permalink_structure);
      $permalink = user_trailingslashit(home_url($permalink));
    }
    return $permalink;
  }
}

/**
 * Public registration method for custom post types with rewrite rules.
 *
 * $permalink_args options:
 * -front: The front of the permalinks for this post type.  All URLs for this post type will start with this
 * -structure: The structure of the permalink.  Accepts the following tags: %year%, %month%, %day% and %{query_var}%, the structure must contain the query var tag
 *
 * @param string $post_type
 * @param array $post_type_args Arguments normally passed into register_post_type
 * @param array $permalink_args Arguments controlling the permalink structure.
 */

function register_post_type_with_rewrite_rules($post_type, $post_type_args = array(), $permalink_args = array()) {
  new Custom_Post_Type_With_Rewrite_Rules($post_type, $post_type_args, $permalink_args);
}

//test code for the above
function register_custom_post_types() {
  register_post_type_with_rewrite_rules('movie-review', array(
    'labels' => array(
      'name' => _x('Movie Reviews', 'post type general name'),
      'singular_name' => _x('Movie Review', 'post type singular name'),
      'add_new' => _x('Add New', 'movie-review'),
      'add_new_item' => __('Add New Movie Review'),
      'edit_item' => __('Edit Movie Review'),
      'new_item' => __('New Movie Review'),
      'view_item' => __('View Movie Review'),
      'search_items' => __('Search Movie Reviews'),
      'not_found' =>  __('No Movie Reviews found'),
      'not_found_in_trash' => __('No Movie Reviews found in Trash'),
      'parent_item_colon' => ''
      ),
    'public' => true,
    'publicly_queryable' => true,
    'query_var' => 'movie-review',
    'rewrite' => false,
    'capability_type' => 'movie-review',
    'hierarchical' => false,
    'supports' => array('title', 'editor', 'excerpt', 'thumbnail'),
  ), array('front'=> 'my-custom-prefix', 'structure'=>'%year%/%author%/%movie-review%'));
}
add_action('init', 'register_custom_post_types');

About the Author
Michael Pretty is an application developer for the Voce Connect Platforms team with a background in developing for PHP, mySQL, WordPress and a handful of other environments. Follow him on Twitter @prettyboymp

Filed in Development, Programming, WordPress

Add Your Comment9 Responses to “Adding Rewrite Rules for Custom Post Types”

Joss on December 7th, 2010 at 5:15 am

This is fantastic work…

I’ve spent hours tearing my hair out over rewrites, writing all sorts of functions to make it go the right places… the PHP class you’ve added at the end of this post does the job, absolutely spot on!

Top notch work on something that’s very hard for anyone to get their head around.

One suggestion to make is to add extra %tag%s to the permalink replacer, specifically for %category% .

Outstanding!

Joss

Joss on December 7th, 2010 at 5:24 am

Only one issue I can see from my initial testing:

When you’ve set a custom permalink structure in the WP Admin, and you use this class to generate permalinks and rewrite rules for a custom post type, then create a new post of that custom post type – the ‘Change Permalinks’ button shows up next to the ‘slug’ field, right below the title.

That’s standard when there’s no permalink structure defined, but your class effectively does add a permalink structure, so the button should be hidden… Clicking it takes you to the Permalinks page, where you’ll see that the permalink structure is already set. Setting it again doesn’t hide that button.

Any idea why that is, or how to fix it?

Thanks!

Joss on December 7th, 2010 at 5:31 am

Also (sorry for triple comment) – in your example code, you set ‘capability_type’ to the query_var eg ‘movie-review’, but this will make it impossible to edit them unless you add that capability manually to the list of caps that an admin can do, etc.

Might be best to set capability_type to ‘post’ by default

Cheers!

Michael Pretty (prettyboymp) on January 6th, 2011 at 9:22 am

Hi Joss,

To be honest, I haven’t messed with adding the %tag% or %category% to the rewrite rules. Usually because a post could have more than one tag or category and it seems arbitrary as to which one would be used in the URL.

I haven’t tested it yet, but I would think that the above would handle it just by adding the %category% or %tag% placeholder to the permastructure and the built in placeholder handlers will handle it. If that alone doesn’t take care of it, then I think also adding code to the filter_post_type_link() function to get the first category or tag and replace those placeholders should complete it. I haven’t yet tested to be sure.

Joss on January 11th, 2011 at 9:24 pm

Hey Michael,

Thanks so much for taking the time to reply. Yeah, I worked the category/tag thing out in the end. Lets say I have a CPT for ‘Questions’, and taxonomies for ‘question-category’ and ‘question-tag’, I’ve added those permastructs to the class so that they’re available.

One question that’s been on my mind – when using your rewrite class, WP doesn’t seem to recognise it as having permalinks enabled – so I get the default ‘Change Permalinks’ nag next to the URL, under the title – where normally you can click ‘Edit’ to change the post slug.

It’s fine for normal pages/posts, and permalinks are enabled and configured – so I’m not sure why that would be the case.

Have you noticed this, or worked out what’s causing it? I could probably fix it and let you know the solution, if I only knew what the prob was!

Thanks!

Joss

The Frosty on January 13th, 2011 at 5:02 pm

Awesome, works like a charm.

@Joss Would love to see your code for category permalink, or see the mods you made.

The Frosty on February 7th, 2011 at 12:44 pm

Still working! But I’ve got a question, what If I wanted to register this with custom taxonomies? For instance instead of category and post_tag I had code_category and code_tag. It seems as if the rewrites can’t recognize it properly.

I’ve changes

get_the_terms
to the newer cat name..

Michael Pretty (prettyboymp) on February 7th, 2011 at 12:59 pm

@The Frosty, if you’re passing in ‘rewrite=true’ when you register your taxonomy, looking at the code, it should be adding a rewrite_tag for your new taxonomy. IE, a taxonomy named ‘code_tag’, with ‘rewrite’=>true, should create a rewrite tag of %code_tag% that you can add to your permastructure.

If you also set a query_var for that taxonomy, it will convert the tag to have the rule of “{$query_var}=”, otherwise, it will add the rule of “taxonomy=$taxonomy&term=”.

Chad on November 17th, 2011 at 3:38 pm

Hi Michael,

Is there a way to have the CPT permalink structure just be mysite.com/%year%/%monthnum%/%day%/%postname%/ without the CPT slug? I’ve used your snippets above and I’m so close!