WordPress routing explained

In this article I’ll explain how WordPress parses request URL and decides what to show and what to do. Unlike other frameworks like Drupal or Kohana where you basically should define the mapping “URL-pattern → function call”, in WordPress the whole process is much more complicated and at the same time less flexible. Of course, there is a nice WP Router plugin, but let’s try to look what WordPress can do on its own.

Module “Except”

Let’s make a toy module called “Except”. Recall that WordPress by default allows you to see all posts of a selected category by requesting http://wp.example.com/category/ABCDE/ URL. What we want from this module is that when someone asks for a URL http://wp.example.com/except/category/ABCDE/ then we show all posts except from category ABCDE.

You can download the final module now from here and learn-by-example or you can stay with me and learn how to build it step by step.

First let’s try and enter those URLs on some WP-supported site. I took Groupon Blog as example:
https://blog.groupon.com/category/cities/ works.
https://blog.groupon.com/except/category/cities/ — Page Not Found. Unsurprisingly WordPress tells us that it doesn’t know what you’re asking for.

Step 0: Skeleton

I start with the default template for a Module.

/* Plugin Name: Except
Plugin URI: http://ruslanbes.com/projects/except
Description: Make urls like http://wp.example.com/except/category/ABCDE/ display all posts except from category ABCDE
Version: 2013.03.26
Author: Ruslan Bes < me@ruslanbes.com >
Author URI: http://ruslanbes.com/devblog
*/

define('EXCEPT_PATH', dirname(__FILE__) .'/');

class Except {

}
$Except = new Except();

It only defines three things:

  1. EXCEPT_PATH constant to reference any files inside the module (not used here but very handy in a plugin-writer life)
  2. A class Except,
  3. A singleton object of that class $Except

Step 1: Hooking into rewrite rules

First thing to do is to tell WordPress that when the module is first activated, WordPress should add new rewrite rules to the default list, and refresh it. And to be clean and nice on deactivate we should remove those rules.

/*
Plugin Name: Except
Plugin URI: http://ruslanbes.com/projects/except
Description: Make urls like http://wp.example.com/except/category/ABCDE/ display all posts except from category ABCDE
Version: 2013.03.26
Author: Ruslan Bes < me@ruslanbes.com >
Author URI: http://ruslanbes.com/devblog
*/

define('EXCEPT_PATH', dirname(__FILE__) .'/');

class Except {
  function __construct() {
      register_activation_hook( __FILE__, array($this, 'activate') );
      register_deactivation_hook( __FILE__, array($this, 'deactivate') );
  }

  function activate() {
    add_action( 'generate_rewrite_rules', array($this, 'add_rewrite_rules') );
    global $wp_rewrite; $wp_rewrite->flush_rules(); // force call to generate_rewrite_rules()
  }

  function deactivate() {
    global $wp_rewrite; $wp_rewrite->flush_rules();
  }

  function add_rewrite_rules(){
    // ???
  }

}
$Except = new Except();

We left the key function add_rewrite_rules() empty for now. I’m gonna do some explanations and then we fill in the code.

How WordPress rewrite rules works

So what are rewrite rules? And where do they come from? And why do we need to hook into them?

First of all, “what”. Rewrite rules is an array like this:

array = 
  category/(.+?)/feed/(feed|rdf|rss|rss2|atom)/?$: string = index.php?category_name=$matches[1]&feed=$matches[2]
  category/(.+?)/(feed|rdf|rss|rss2|atom)/?$: string = index.php?category_name=$matches[1]&feed=$matches[2]
  category/(.+?)/page/?([0-9]{1,})/?$: string = index.php?category_name=$matches[1]&paged=$matches[2]
  category/(.+?)/?$: string = index.php?category_name=$matches[1]
  tag/([^/]+)/feed/(feed|rdf|rss|rss2|atom)/?$: string = index.php?tag=$matches[1]&feed=$matches[2]
  tag/([^/]+)/(feed|rdf|rss|rss2|atom)/?$: string = index.php?tag=$matches[1]&feed=$matches[2]
  tag/([^/]+)/page/?([0-9]{1,})/?$: string = index.php?tag=$matches[1]&paged=$matches[2]
  tag/([^/]+)/?$: string = index.php?tag=$matches[1]
  ...

This array is stored in the database under the option rewrite_rules and it is loaded into WordPress inside WP_Rewrite::wp_rewrite_rules(). As you may already figured out from the above example, rewrite_rules contains instructions on how to transform URL from address bar into the usual GET string. It is needed to support Human Readable URLs, that is, to send both Alice who typed http://wp.example.com/category/carol and Bob who typed http://wp.example.com/index.php?category_name=carol to the same page. That’s all. This also explains why do we care about it — because we want to have our nice short URLs. And this also answers the question why it is stored into the option and not generated on every page load — simply because normally it shouldn’t change between page calls.

Step 2½: Setting our rewrite rules

So now we know what we need to add to the rewrite rules in our add_rewrite_rules() code. We probably need something like:

array = 
  except/category/(.+?)/feed/(feed|rdf|rss|rss2|atom)/?$: string = index.php?except_category_name=$matches[1]&feed=$matches[2]
  except/category/(.+?)/(feed|rdf|rss|rss2|atom)/?$: string = index.php?except_category_name=$matches[1]&feed=$matches[2]
  except/category/(.+?)/page/?([0-9]{1,})/?$: string = index.php?except_category_name=$matches[1]&paged=$matches[2]
  except/category/(.+?)/?$: string = index.php?except_category_name=$matches[1]

Let’s try it!

/*
Plugin Name: Except
Plugin URI: http://ruslanbes.com/projects/except
Description: Make urls like http://wp.example.com/except/category/ABCDE/ display all posts except from category ABCDE
Version: 2013.03.26
Author: Ruslan Bes < me@ruslanbes.com >
Author URI: http://ruslanbes.com/devblog
*/

define('EXCEPT_PATH', dirname(__FILE__) .'/');

class Except {
  function __construct() {
      register_activation_hook( __FILE__, array($this, 'activate') );
      register_deactivation_hook( __FILE__, array($this, 'deactivate') );
  }

  function activate() {
    add_action( 'generate_rewrite_rules', array($this, 'add_rewrite_rules') );
    global $wp_rewrite; $wp_rewrite->flush_rules(); // force call to generate_rewrite_rules()
  }

  function deactivate() {
    global $wp_rewrite; $wp_rewrite->flush_rules();
  }

  function add_rewrite_rules($wp_rewrite){
    $rules = array(
      'except/category/(.+?)/feed/(feed|rdf|rss|rss2|atom)/?$'  => 'index.php?except_category_name=$matches[1]&feed=$matches[2]',
      'except/category/(.+?)/(feed|rdf|rss|rss2|atom)/?$'       => 'index.php?except_category_name=$matches[1]&feed=$matches[2]',
      'except/category/(.+?)/page/?([0-9]{1,})/?$'              => 'index.php?except_category_name=$matches[1]&paged=$matches[2]',
      'except/category/(.+?)/?$'                                => 'index.php?except_category_name=$matches[1]',
      );
    $wp_rewrite->rules = $rules + (array)$wp_rewrite->rules;
  }

}
$Except = new Except();

Great. Now activate the module and try to go to http://wp.example.com/except/category/enter_some_gibberish_here/. Instead of Page Not Found error you will now see the main page of your blog. That’s much better. WordPress knows that the URL is valid but doesn’t yet know what to show you, so it shows you all posts.

Step 3: Filtering the posts

Now we need to hook into WordPress query and filter the posts. Luckily there is a beautiful place for it — the pre_get_posts hook. Let’s try it.

/*
Plugin Name: Except
Plugin URI: http://ruslanbes.com/projects/except
Description: Make urls like http://wp.example.com/except/category/ABCDE/ display all posts except from category ABCDE
Version: 2013.03.26
Author: Ruslan Bes < me@ruslanbes.com >
Author URI: http://ruslanbes.com/devblog
*/

define('EXCEPT_PATH', dirname(__FILE__) .'/');

class Except {
  function __construct() {
      register_activation_hook( __FILE__, array($this, 'activate') );
      register_deactivation_hook( __FILE__, array($this, 'deactivate') );
      add_action( 'pre_get_posts', array($this, 'pre_get_posts') );
  }

  function activate() {
    add_action( 'generate_rewrite_rules', array($this, 'add_rewrite_rules') );
    global $wp_rewrite; $wp_rewrite->flush_rules(); // force call to generate_rewrite_rules()
  }

  function deactivate() {
    global $wp_rewrite; $wp_rewrite->flush_rules();
  }

  function add_rewrite_rules($wp_rewrite){
    $rules = array(
      'except/category/(.+?)/feed/(feed|rdf|rss|rss2|atom)/?$'  => 'index.php?except_category_name=$matches[1]&feed=$matches[2]',
      'except/category/(.+?)/(feed|rdf|rss|rss2|atom)/?$'       => 'index.php?except_category_name=$matches[1]&feed=$matches[2]',
      'except/category/(.+?)/page/?([0-9]{1,})/?$'              => 'index.php?except_category_name=$matches[1]&paged=$matches[2]',
      'except/category/(.+?)/?$'                                => 'index.php?except_category_name=$matches[1]',
      );
    $wp_rewrite->rules = $rules + (array)$wp_rewrite->rules;
  }

  function pre_get_posts( $query ) {
      if ( ! is_admin() && $query->is_main_query() && $query->get( 'except_category_name' ) ) { // check if user asked for a non-admin page and that query contains except_category_name var
          $category = get_category_by_slug( $query->get( 'except_category_name' ) ); // get the category object
          $query->set( 'cat', '-' . $category->term_id ); // set the condition - everything except that category
          $query->is_home = FALSE; // Tell WordPress we are not at home page
      }
  }
}
$Except = new Except();

Try now some testing with some real category name http://wp.example.com/except/category/news/.

Oops! It Looks we were ignored

Why?

The trouble is in the $query->get( 'except_category_name' ) call. The truth is that WordPress obediently transforms your call to index.php?except_category_name=news but it does not automatically extract that value from the query string. It extracts only those vars which you are explicitly asked him to extract (I’ll explain how to do it in a second). And that’s the most stupid thing in this whole process. It is quite obvious that if I defined a rewrite rule with some_var=some_value part then I want to read that some_value! Otherwise I wouldn’t bother defining it would I? And I have no idea why WordPress developers decided it the other way and why they added additional step that just makes things more complicated and a tiny bit slower.

Step 4: Missing Chain. Query Var

Okay we can further complain about unfairness of the world, but job is job — we need to finish it. And the thing we need to update is the array called $public_query_vars. It’s time to do it.

/*
Plugin Name: Except
Plugin URI: http://ruslanbes.com/projects/except
Description: Make urls like http://wp.example.com/except/category/ABCDE/ display all posts except from category ABCDE
Version: 2013.03.26
Author: Ruslan Bes < me@ruslanbes.com >
Author URI: http://ruslanbes.com/devblog
*/

define('EXCEPT_PATH', dirname(__FILE__) .'/');

class Except {
  function __construct() {
      register_activation_hook( __FILE__, array($this, 'activate') );
      register_deactivation_hook( __FILE__, array($this, 'deactivate') );
      add_action( 'pre_get_posts', array($this, 'pre_get_posts') );
      add_filter( 'query_vars', array($this, 'query_vars') );
  }

  function activate() {
    add_action( 'generate_rewrite_rules', array($this, 'add_rewrite_rules') );
    global $wp_rewrite; $wp_rewrite->flush_rules(); // force call to generate_rewrite_rules()
  }

  function deactivate() {
    global $wp_rewrite; $wp_rewrite->flush_rules();
  }

  function add_rewrite_rules($wp_rewrite){
    $rules = array(
      'except/category/(.+?)/feed/(feed|rdf|rss|rss2|atom)/?$'  => 'index.php?except_category_name=$matches[1]&feed=$matches[2]',
      'except/category/(.+?)/(feed|rdf|rss|rss2|atom)/?$'       => 'index.php?except_category_name=$matches[1]&feed=$matches[2]',
      'except/category/(.+?)/page/?([0-9]{1,})/?$'              => 'index.php?except_category_name=$matches[1]&paged=$matches[2]',
      'except/category/(.+?)/?$'                                => 'index.php?except_category_name=$matches[1]',
      );
    $wp_rewrite->rules = $rules + (array)$wp_rewrite->rules;
  }

  function pre_get_posts( $query ) {
      if ( ! is_admin() && $query->is_main_query() && $query->get( 'except_category_name' ) ) { // check if user asked for a non-admin page and that query contains except_category_name var
          $category = get_category_by_slug( $query->get( 'except_category_name' ) ); // get the category object
          $query->set( 'cat', '-' . $category->term_id ); // set the condition - everything except that category
          $query->is_home = FALSE; // Tell WordPress we are not at home page
      }
  }

  function query_vars($public_query_vars){
    array_push($public_query_vars, 'except_category_name');
    return $public_query_vars;
  }

}
$Except = new Except();

If you test your code now you’ll see it filters out the category. But that’s not all.

Step 5: Not all? You’ve got to be kidding me

To be honest we have a tiny problem with the very first step when we defined rewrite rules. To illustrate it, think of a following case. You have one module Except_Category and you have a second module Except_Tag which does the same thing but for tags. Now imagine you’re activating them one after another. Here is what will happen to the rewrite rules.
rewrite rules
So, rewrite rules from the module which was activated first are not preserved. Why?

The thing is that when you go to WordPress Dashboard and activate a module Except_Tag, WordPress will call Except_Tag::activate() function. But it will do that only for this new module — since all other modules are already activated, there is no reason to call activate hook for them.

So, WordPress will execute only these two lines

class Except_Tag {
  …
  function activate() {
    add_action( ‘generate_rewrite_rules’, array($this, ‘add_rewrite_rules’) ); // Except_Tag rules
    global $wp_rewrite; $wp_rewrite->flush_rules(); // force call to generate_rewrite_rules()
  }
  …
}

That means that at the moment of $wp_rewrite->flush_rules() the rules from Except_Category::activate() will be missed.

So putting new rewrite rules into activation hook is a bad idea.

Let’s think. We need to put them into a function that satisfies these two conditions:
1) It is called ONLY when the module is active and not just during activation event.
2) It is called as early as possible. That is, we need to put them them before anyone might call $wp_rewrite->flush_rules()

We know that during init stage, WordPress looks which modules are active and calls include_once(‘…/module/module.php’) for each of them. And we have a nice last line in our main module file:

$Except = new Except();

So it seems, that Except::__construct() is the perfect place for that. Let’s try it.

/*
Plugin Name: Except
Plugin URI: http://ruslanbes.com/projects/except
Description: Make urls like http://wp.example.com/except/category/ABCDE/ display all posts except from category ABCDE
Version: 2013.03.26
Author: Ruslan Bes < me@ruslanbes.com >
Author URI: http://ruslanbes.com/devblog
*/

define('EXCEPT_PATH', dirname(__FILE__) .'/');

class Except {
  function __construct() {
      register_activation_hook( __FILE__, array($this, 'activate') );
      register_deactivation_hook( __FILE__, array($this, 'deactivate') );
      add_action( 'generate_rewrite_rules', array($this, 'add_rewrite_rules') );
      add_action( 'pre_get_posts', array($this, 'pre_get_posts') );
      add_filter( 'query_vars', array($this, 'query_vars') );
  }

  function activate() {
    global $wp_rewrite; $wp_rewrite->flush_rules(); // force call to generate_rewrite_rules()
  }

  function deactivate() {
    remove_action( 'generate_rewrite_rules', array($this, 'add_rewrite_rules') );
    global $wp_rewrite; $wp_rewrite->flush_rules();
  }

  function add_rewrite_rules($wp_rewrite){
    $rules = array(
      'except/category/(.+?)/feed/(feed|rdf|rss|rss2|atom)/?$'  => 'index.php?except_category_name=$matches[1]&feed=$matches[2]',
      'except/category/(.+?)/(feed|rdf|rss|rss2|atom)/?$'       => 'index.php?except_category_name=$matches[1]&feed=$matches[2]',
      'except/category/(.+?)/page/?([0-9]{1,})/?$'              => 'index.php?except_category_name=$matches[1]&paged=$matches[2]',
      'except/category/(.+?)/?$'                                => 'index.php?except_category_name=$matches[1]',
      );
    $wp_rewrite->rules = $rules + (array)$wp_rewrite->rules;
  }

  function pre_get_posts( $query ) {
      if ( ! is_admin() && $query->is_main_query() && $query->get( 'except_category_name' ) ) { // check if user asked for a non-admin page and that query contains except_category_name var
          $category = get_category_by_slug( $query->get( 'except_category_name' ) ); // get the category object
          $query->set( 'cat', '-' . $category->term_id ); // set the condition - everything except that category
          $query->is_home = FALSE; // Tell WordPress we are not at home page
      }
  }

  function query_vars($public_query_vars){
    array_push($public_query_vars, 'except_category_name');
    return $public_query_vars;
  }

}
$Except = new Except();

Now everything is fine. As long as the module is enabled additional rewrite rules will be there. But the moment it is being deactivated the rules are instantly removed

That’s it. If you need the module you can get it from here .

5 thoughts

  1. I don’t understand why except_tags will overwrite the rules for except_category … when both are pushing their rules in the array… (shouldn’t be the array referenced though?)

    1. You are right. I didn’t explained that properly.

      The thing is that when you go to WordPress Dashboard and activate a module Except_Tag, WordPress will call Except_Tag::activate() function. But it will do that only for this new module — since all other modules are already activated, there is no reason to call activate hook for them.

      So, WordPress will execute only these two lines

      class Except_Tag {

      function activate() {
      add_action( ‘generate_rewrite_rules’, array($this, ‘add_rewrite_rules’) ); // Except_Tag rules
      global $wp_rewrite; $wp_rewrite->flush_rules(); // force call to generate_rewrite_rules()
      }

      }

      That means that at the moment of flush_rules() the rules from Except_Category::activate() will be missed.

      So putting new rewrite rules into activation hook is a bad idea.

      Let’s think. We need to put them into a function that satisfies these conditions:
      1) It is called ONLY when the module is active and not just during activation event.
      2) It is called as early as possible. That is, we need to put them them before anyone might call flush_rules()

      We know that during init stage, WordPress looks which modules are active and calls include_once(‘…/module/module.php’) for each of them. And we have a nice last line in our module:

      $Except = new Except();

      So, Except::__construct() is the perfect place for that. And that is exactly what I’m doing on step 5

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.