I found the WordPress documentation about custom rewrite rules pretty confusing and most of the blog posts / Stackoverflow posts I could find weren’t much better. Silly thing is it’s actually pretty simple to set up your own rewrite rules when you know how. Here’s what I figured out:

  • You want to create your rewrite rules on the WordPress init action hook AND the plugin activation hook.
  • You should also flush your rewrite rules after creating them in the activation hook.
  • You also need to create custom “rewrite_tags” to access route parameters in the init hook.
  • The rewrite rule seemingly has to point to index.php, so you have to use GET parameters instead of using pretty links in the rule.
  • Pretty permalinks must already be enabled!
  • Flushing the rules on the deactivation hook doesn’t seem to do much good!

So let’s look at an example, we want to have a page called items that’s controlled by our code. We also want to make it so that a user can view any item by browsing to a URL like /items/view/14 and the code needs to be able to access the item ID.

<?php
/*
Plugin Name: Example
Version: 0.1.0
Author URI: http://kzar.co.uk/blog/2013/10/01/wordpress-rewrite-rules/
*/
class Example {
  private static $instance;

  function __construct() {
    // Set up our hooks
    add_action('init', array($this, 'init'));

    register_activation_hook(__FILE__, array($this, 'activate'));
    register_deactivation_hook(__FILE__, array($this, 'deactivate'));

    add_shortcode('example-plugin', array($this, 'plugin_shortcode'));
  }

  public static function getInstance() {
    if (!self::$instance) {
      self::$instance = new Example();
    }

    return self::$instance;
  }

  function rewrite_rules() {
    // Add our rewrite rule and rewrite tag
    add_rewrite_rule('^items/view/([0-9]+)$', 'index.php?pagename=items&itemid=$matches[1]', 'top');
  }

  function init() {
    add_rewrite_tag('%itemid%', '([0-9]+)');
    $this->rewrite_rules();
  }

  function plugin_shortcode() {
    return '<b>View ID is ' . get_query_var('itemid') . '</b>';
  }

  function activate() {
    // Make sure pretty permalinks are enabled
    global $wp_rewrite;
    if (!$wp_rewrite->permalink_structure) {
      echo("Plugin requires pretty permalinks to be enabled!");
      exit();
    }

    // Create our items page (With shortcode already in content)
    if (!get_page_by_path('items')) {
      wp_insert_post(array(
        'post_title' => 'Items',
        'post_content' => '[example-plugin]',
        'post_status' => 'publish',
        'comment_status' => 'closed',
        'ping_status' => 'closed',
        'post_type' => 'page'
      ));
    }

    // Create our rewrite rule
    $this->rewrite_rules();
    flush_rewrite_rules();
  }

  function deactivate() {
    // Remove the page
    $page = get_page_by_path('items');
    if ($page) {
      wp_delete_post($page->ID);
    }

    // Remove our rewrite rule
    // FIXME does not work as the init hook has already been called
    flush_rewrite_rules();
  }
}

$example = Example::getInstance();

Put that in your plugins directory - call it example.php - and then activate the Example plugin from wp-admin. You should see in the frontend that a page /items has been created, and if you browse to /items/view/1324 it will display 1324 back to you. Deactivate the plugin and the page will be deleted and things will mostly be cleaned up.

Now, this example’s not perfect; deactivation of the plugin does not flush away our rewrite rule! Why?! Well, as the init function is called before the plugin is deactivated, the rule is already in memory when the rules are flushed. I’m not sure the best way to overcome that, although I don’t think it’s the most significant issue as the next time the rules are flushed by WordPress or a plugin our old rule will be removed. (Hint you can browse to the permalinks page in wp-admin to flush your rewrite rules.)

Why do we have to create our rewrite rules from both the init hook and activation hook? Well, it appears that the init hook is not called until after the plugin is activated, but we can only flush on activation. So to have our rules work, we must create them at activation. But if something else flushes the rules, and we haven’t created them beforehand they’ll be removed, so we need to create them each time at activation just in case! So the only way to cover all bases appears to be to create the rewrite rules on both init and activation!

OK, but what’s with the rewrite tags? Honestly, I have no idea, but if you don’t jump through that hoop get_query_var doesn’t return them, and FYI $_REQUEST doesn’t contain them either.

Finally I’d like to draw attention to the second parameter of our add_rewrite_rule call: 'index.php?pagename=items&itemid=$matches[1]'. Originally I tried something like items/?itemid=$matches[1] but that does not work, you have to make sure it’s in terms of index.php. A bit confusing to start with but not a problem, you can do everything you need to that way using GET parameters like pagename.

Hope that helps! Dave.