Designing a system: WordPress routing

Developers use WordPress to build all sorts of solutions. They can range from a small website to large application platforms. The larger the project gets, the more common it is to have the need for WordPress to handle custom URLs.

You might want to map a custom URL to a new template, a specific hook or both. These situations get more and more common as you work on larger WordPress projects. This type of problem is a bit of a growing up pain with WordPress.

In framework land, there’s a tool that helps you with that problem. It’s called the routing system. It’s a critical component of most frameworks. It lets you map URLs with different parts of your application.

It’s a tough problem to solve, but a good example of object-oriented design. That’s why we’re going to build one. It’ll show you how object-oriented programming helps you solve harder problems.

But isn’t this the job of the rewrite API?

Not quite. (But excellent question!) The rewrite API allows WordPress to understand human-readable URLs. There’s no question that it’s an important task.

That said, a routing system is a large enough problem that it’s made up of smaller sub-problems. Solving these sub-problems is necessary for the routing system to do its job. And, as you’ll see next, the rewrite API itself only takes care of one of these sub-problems.

The responsibilities of a routing system

To handle these problems, we’re going to break the routing system into more than one class. We’ll keep each class focused by limiting them to a single responsibility.

Let’s look at what these are.

Map custom URLs

The most basic need of a routing system is the ability to map custom URLs. It needs a way to know what you expect it to do when it receives a matching URL. This is what we call a route.

Routes are responsible for mapping the relationships between custom URLs and WordPress. They tell the routing system what to do when it matches a custom URL. Each route represents a single relationship and answers specific questions such as:

  • What hook do you want to call?
  • What template do you want to load?

Match a request to a route

The next thing the routing system needs to do is match a request to a route. This lets the routing system know which route it needs to process. The router is in charge of that job.

You can register routes with it. It’ll take a request that WordPress received and attempt to match it to one of them. But to do that, it needs to understand the request that WordPress received.

This is where the rewrite API becomes interesting. Its job is to transform a URL into something that WordPress can understand. The router also needs to recognize URLs to do its job.

Could the rewrite API help the router do that then? Of course, it could!

We’d have to design the router as a bridge between the routing system and the rewrite API. On one side, it would have to transform the registered routes so that the rewrite API could use them. On the other, it would have to take the information from the rewrite API and use it to match a route.

Router-Graph

Process a route

Once the router matched a request to a route, the routing system needs to process it. This comes down to looking at the questions answered by the matched route.

  • Does it want to call a hook?
  • Does it want to load a template?

This is how the routing system will process the matched route. It’ll do it based on how it answered these questions.

So let’s say that the router matched a route. If it wants to call a hook, the routing system will call it. If it wants to load a template, the routing system will pass it to the WordPress template loader.

Limitations of the system

Routing isn’t an easy problem to solve. That said, we can make it easier by limiting what the routing system can do. Even with these limitations, the routing system is quite useful.

No route requirements

Route requirements let you create routes that are more granular. For example, you can create a route and restrict it to a specific HTTP method. It’s a powerful feature for sure.

That said, supporting route requirements demands more out of the routing system. It can’t just match a route to a URL anymore. It also has to make sure that the request matches the other requirements as well.

No path variables

Path variables let you define routes with dynamic URLs like /product/{id}. When the routing system matches these routes, it extracts the variables from the URL. These variables then get sent to our hook as arguments or our template as variables.

There’s no doubt that this is an important feature. It’s even mandatory in some cases. But it also adds a tremendous amount of complexity to the routing system. That’s why we won’t support them here.

Building our routing system

Alright, we laid some initial groundwork for our routing system. It’s time to start building it! Now, you might be wondering, “Where do I start?”. That’s a valid question for you to have.

Finding a starting point

Building a routing system is a large task. It’s like looking up at a mountain that you have to climb. It’s normal to feel intimidated by it. You don’t always know what’s a good starting point. And, once you found one, how do you build out the entire system?

A good way to get over these issues is to try to start with the most isolated component in the system. You then work your way up from there. So what’s the most isolated component in the routing system?

It’s the route. A route just stores the mapping information for the routing system. It has no dependencies on other parts of the routing system.

So that’s where we’ll start.

Route class

The job of our Route class is simple. We want it to be able to answer the two questions we saw earlier for a given URL. These were:

  • Do you want to call a hook? If so which one?
  • Do you want to load a template? If so which one?

That’s it! It’s not any more complicated than that. We just need to build it now.

Constructing a Route object

Let’s look at the constructor first as it sets the initial state of our Route object. What does the __construct method need to create a Route object?

As you can see above, the __construct takes three parameters: path, hook and template. The only required parameter is path. It represents the URL path that we want the route to match. We can’t have a route without it.

hook and template are optional. That’s an intentional design decision. These two parameters represent the two questions we want our route to answer. We just don’t want to force a route to answer either question by default.

The method itself doesn’t do much. It assigns each parameter to internal variables with the same name. And each of these internal variables is private.

Getting information out of our Route object

Making our internal variables private has a (good) side-effect. Others can’t see what’s inside our Route objects by default. We need to create ways for them to see what’s inside them.

That’s the job of these getter methods. They give others read access to the variables inside our Route class. Each method starts with get_ and ends with the variable name.

As a note, we won’t be creating ways for others to change the variables inside our class. This is another design decision. We don’t want to allow others to change a Route object once created. We call these types of objects: immutable objects.

Does a route answer a question?

Now that we have getter methods, there’s a way to get the route information out of our Route objects. That said, there are situations where you don’t need to the answer to the question. Rather, you only want to know if it answers the question or not.

This isn’t hard to do. We know that the defaults for hook and template is an empty string. You can just use empty function to check.

So you could just use the get method and do the empty check yourself. You’d have to do it every time you want to know if the Route object answers the question not. But why should you have to do that? You shouldn’t have to.

That’s why has_hook and has_template are good helper methods to have. You remove code that you have to copy/paste everywhere. This improves the usability of our Route class. This improves the quality of life of anyone that wants to use our class.

Router Class

Now that we have our Route class, it’s time to move on to the largest piece of the puzzle: the Router class. As we saw earlier, it has to do a lot of work for the routing system. It needs to:

  • Manage the Route objects
  • Act as a bridge between the rewrite API and the routing system
  • Match requests received by WordPress to a Route object

Handling these tasks is what gives it its size. They’re also a good way to break down the work we need to do to build the Router class. But enough talking about it, let’s start building it!

Managing routes

The basic need of the Router class is to manage Route objects. Lucky for us, we don’t have to make it complicated. We can just do it by managing an internal array variable that contains our Route objects.

As you can see, there’s nothing fancy with our constructor. As you can see, our constructor reflects this basic need. It has a routes parameter that lets you pass an initial set of routes. We assign these initial routes to the internal routes variable.

That said, we shouldn’t limit adding routes to the initial Router object construction. We might want to add more Route objects to it later on. That’s a reasonable scenario to expect.

The add_route method fixes the issue. It takes a Route object as a parameter and then appends it to the routes array.

We can also use our add_route method in our constructor to make it safer.

This is our updated __construct method. The role of for loop is to act as an extra validation step. It passes each element of the routes array to the add_route method.

Because add_route uses type hinting, we’re now confident that routes only contains Route objects. We can also update the DocBlock to reflect this by changing it to Route[]. This implies that we expect an array of Route objects as a parameter.

Bridging the rewrite API

So how do you use the rewrite API with a routing system? We’ve been dodging the question so far. The answer is that we want to use rewrite rules.

With the help of rewrite rules, the router can know when it should try to match a request to a Route object. But how can the router know that? The trick is to add a unique name to each route.

Here’s a simple example of a rewrite rule for a route with the path /my-plugin. ^my-plugin$ is the regular expression that matches the path of our route. index.php?route_name=my_plugin_index is the URL that WordPress redirects our route to.

The important part is route_name=my_plugin_index. It’s what tells the router, “Hey! I’m a URL you should care about. LOOK AT ME.” In this case, it wants the router to look (Yes yes, we’re looking at you. Happy!?) for a route named my_plugin_index.

Converting routes to rewrite rules

So now that we know what the rewrite API secret sauce is, we need our router to use it. First, we want to transform our registered Route objects into rewrite rules. Let’s create a method to do that.

Here’s our first pass at the add_rule method. We have two parameters: name and route. name is the route name that we want to give it like my_plugin_index.

We’re going to leave the inside empty for now. We need to lay down a bit of groundwork first. So let’s take care of that.

Assigning a name to a route

So far, we haven’t coded anything to track the name of a route for add_rule. This is the first thing that we’re going to fix. The simplest way to do this is to change the structure of our routes array. We’ll make it an associative array where the name of the route is the key.

We updated our add_route method to reflect this change. It now takes an extra name parameter. name is then used as the key when it adds route to the routes array.

We also need to update our constructor. It now assumes that the given routes array is an associative array with the route name as the key. The foreach loop now passes the name key to add_route.

Defining a route variable

As you saw earlier, route_name= is what lets the rewrite rule identify a route. It’s not a good idea to hardcode the route variable name. So let’s give someone the option to override it.

This brings us back to the __construct method again. We’re going to add route_variable as a method parameter. We’ll keep the route_name as the default. route_variable also gets assigned to an internal variable with the same name.

Converting our route path into a regular expression

We also need a way to transform our route path to a regular expression. This lets us turn /my-plugin into ^my-plugin$.

generate_route_regex takes a Route object as a parameter. It uses that Route object and gets the route path using get_path. It then makes it all pretty for the rewrite API by applying a few trims here and there on it.

To do that, it starts by using trim to remove all spaces from both sides of the route path. It also uses ltrim to remove any / at the beginning of the path. The rewrite API doesn’t want its regular expressions starting with them.

It finishes off by adding ^ to the beginning of the string and $ at the end. These are special regular expression characters. ^ only matches the beginning of a string while $ only matches at the end.

We add them to prevent matching errors. They force the rewrite API to do an exact match to our URL paths.

Going back to the add_rule method

So, let’s recap what we’ve done so far. We started tracking the name of every registered route. We added the internal route_variable variable to let someone change the route variable name. We also created generate_route_regex to convert a route path into a regular expression.

Now, we’ll use all these pieces to complete our add_rule method.

The first you might have noticed is that we added position as a third parameter. This lets us tweak the default position where the rewrite API add the rule for a route. By default, it would get appended to the all other rewrite rules. We’d rather have the rewrite API prepend it so that it can process it first. That’s why the default value is top.

Looking at the completed add_rule method, it’s just a wrapper around the add_rewrite_rule function. It converts our three parameters (name, route and position) into three arguments for add_rewrite_rule.

The first argument of the add_rewrite_rule is the regular expression of the new rule. So that’s where we’ll use the output of the generate_route_regex method. The second argument is the redirect URL that WordPress will process. We create that redirect URL by concatenating route_variable and name inside a string. The third argument is the position parameter described earlier.

Registering everything with the rewrite API

So far, we’ve seen how to convert a single Route object into a rewrite rule. We still need a method that puts everything together. That’s the last piece of the puzzle.

That’s the goal of the compile method. It compiles everything in the Router object into rewrite rules. It passes every registered route and its name in the router to add_rule.

Right now, the router still wouldn’t work with the rewrite API. That’s because we didn’t register the route_variable with it. Let’s fix that.

We added a call to add_rewrite_tag to register route_variable as a rewrite tag. We pass it the route_variable prepended and appended with %. We use (.+) as the regular expression. This tells it to match any character 1 or more times.

Matching a route

At this point, we’ve handled everything bridging our routing system to the rewrite API. But that’s only one direction! We still need to handle converting information from the rewrite API to a route.

Lucky for us, this isn’t as complicated as what we’ve done so far. All the information that we need is in the internal WordPress query variables. We just need to look over them.

And that’s exactly what the match method does. It takes the array of query variables as a parameter. It then tries to find a matching route or returns a WP_Error object with a relevant error code.

The first step is to check if there’s even a route name stored inside query_variables. To do that, we do an empty check on query_variables using route_variable as the key. If it’s not empty, it’ll contain the name of the route we’re looking for. Otherwise, we send back a WP_Error with missing_route_variable as the error code.

Now that we have a route name, we just need to check if there’s a route registered with that name. We can do that using the isset function. It lets us verify that there’s a Route object stored in our routes array. that using on our routes. If there’s a route stored at route_name location, we pass it back. Otherwise, we return another WP_Error with the route_not_found error code this time.

Processor Class

So far, we’ve stayed pretty far away from WordPress. Sure, we created a bridge between the routing system and the rewrite API, but that’s it. We still need to take care of the larger WordPress picture next.

What’s involved in doing that?

Well, we need to find out if there’s a route that matches the current request. Using that route, we have to call a specific hook, load a specific template or do both. There’s also the question of compiling our router and flushing the rewrite rules.

That’s still quite a bit of work left to do.

This will all be the job of our Processor class. It’ll be in charge of wiring the routing system with the rest of WordPress using the plugin API. Let’s get to it!

Constructor and the plugin API

Since we’ll be using the plugin API, we have to design our class around that. You can pick the option that you prefer here. But for this example, we’ll use a custom constructor.

This shows the initial setup for the Processor class. There isn’t much to see there yet, but that’s intentional. We’ll fill the rest out as we add functionality to our class.

We have our __construct method and init as the custom constructor. Both take a Router object as a parameter. That way the __construct method can assign it to the router internal variable.

Processor::init($router); initializes everything with WordPress. We create a Router object outside the class and pass it to the constructor. This is just meant to serve as an example. The router variable could come from anywhere in your plugin.

The important is that you pass it to the constructor. That way we don’t create a tight coupling between the two classes. We don’t want to create the router inside the Processor class.

Loading routes

We have the skeleton of our Processor class, but it’s still pretty empty. The next thing we want to do is add routes to it. That way we can load them into the router.

The first thing to do is to change our existing __construct and init methods. They now have routes as a second parameter with an empty array as the default value. It’s an array of routes that we want to register with WordPress.

We also updated our external call to Processor::init. We created an external array of routes with one Route object in it. Our route has my_plugin_index as its name and /my-plugin as its path.

Why don’t we pass the routes into our Router object right away? That’s a valid question to ask yourself. And there’s a good reason for that! (isn’t there always?)

We want to delay when our Processor class added the routes to the router. That way any plugin or theme can filter them. This lets them either add or remove routes before they get registered with WordPress.

All this happens through the register_routes method. We attached it to the init hook in our init method (a decision approved by the redundancy department of redundancy). This pushes the route registration to the last possible moment during loading.

Any plugin or theme can then use the my_plugin_routes filter to change the array of routes. Once the routes filtered, the method loops through them and adds them to the router.

Compiling and flushing routes

Now that all our routes are in our router, there’s still the question of compiling it into rewrite rules. The good news is that we’ve already done all the work for it. We just need to call our compile method in register_routes.

So that was good news, but there’s also some bad news. Adding rewrite rules is just one of two steps when using the rewrite API. These rewrite rules won’t take effect until we flush them using flush_rewrite_rules.

The problem is that flush_rewrite_rules is an expensive function. We can’t always call it at the end of our compile method. We need to add some logic to only do it when needed.

The solution is to keep track of the routes that we registered the last time we flushed them. To do that, we need to hash our routes into a unique string. We start by serializing our routes into a string using the serialize function. We then hash that string with the md5 function. This gives us a unique string to represent our routes.

We store this string in an option called my_plugin_routes_hash. register_routes compares that option to the value it gets from hashing our routes. If the value is different, it calls flush_rewrite_rules. It also saves the routes_hash as our new my_plugin_routes_hash option.

Matching a request to a route

We’re almost at the finish line! We’ve done most of the heavy lifting. The rewrite API has all the information it needs to process our routes. We just need to check if it found anything.

Lucky for us, there’s an action hook designed just for that purpose: parse_request. It fires when the WordPress finishes parsing the query variables of the incoming request. It passes the current WordPress environment as an argument.

match_request is the method that we’ll hook to parse_request. We updated our init method to register it. It takes a WP object as a parameter. That’s the WordPress environment class.

The method itself is pretty simple. It calls the router match method. It’ll return either a Route or a WP_Error. If we have a Route object, we store it in the matched_route internal variable. This is how the Processor class knows if there’s a matched route or not.

This last little bit is an optional step so feel free to ignore it. It’s an example of what you can do with the WP_Error objects sent back by the router. In this case, we want to stop the WordPress process with a 404 error when we can’t find a route.

Why do that?

It’s just a way to prevent WordPress from doing something we don’t expect. We know from the match method that it found our route variable. It just couldn’t find a route using it. We’re not sure how WordPress would handle it so we stop the process.

Processing the matched route

Let’s say that the match_request method found something. We need to check which questions it answers. Does it want to call a hook, load a template or both?

Calling the route hook

call_route_hook handles whether the matched route wants to call a hook or not. The tricky question is “When do we want to call it?” While we could use any hook, template_redirect is (in my opinion) the best one for the job.

Why? Well, it’s the last hook before the WordPress templating system goes into high gear. At that point, the WordPress process has done everything, but generate HTML. It’s the perfect location to create custom responses outside the templating system.

Now, let’s look at the call_route_hook method. The first thing it does is to check if the router even found a route. We use an instanceof check on matched_route to see if it’s a Route object. You also want to check if it even has a hook to call. If matched_route passes both these tests, we call the route hook using do_action.

Loading the route template

load_route_template looks at whether the matched route wants to load a template or not. Unlike call_route_hook, there’s only one good hook for this method. It’s the template_include hook. It lets you filter the path of the template that the templating system wants to load.

We start by making similar tests as call_route_hook. We check if we have matched route and if it has a template. If the tests fail, we return the template value and stop there.

Now, if it does pass the test, we could just return the value from the get_template method. But the truth is that it’s not a great solution. Instead, we’ll use get_query_template to locate it. That way, the route only needs to pass a filename without the extension. The function will take care of the rest. If get_query_template locates a template, we replace the value of template with it.

The routing system at work

To finish things off, let’s see what our hard work looks like in practice. It shows two different situations where you can use the routing system. Each of them will have their own route.

The my_plugin_index route shows how to route to an index page. The routing system will use the route when URL path matches /my-plugin. It doesn’t want to call any hook. Instead, it wants to load a template with the my-plugin-index.php filename.

The my_plugin_redirect route is a template-less route. It only wants the routing system to call the my_plugin_redirect hook. It expects the hook to take care of generating a response.

That’s what the my_plugin_redirect function (I know, original name) does. We use / as the default path. It then checks if the request had a location value. If it does, it redirects to that.

Looking at the big picture

This is a lot to digest. It’s not a walk in the park to build something like a routing system. Even more so if you’re struggling with object-oriented programming. It might even feel a bit insane.

It’s the catch-22 of object-oriented programming. It’s with these types of large systems where it shines brightest. Except that you need to feel comfortable with it to build them.

The good news is that our routing system is reusable. You can find all the code on GitHub. It’s all ready for you to use it on your own projects.

All that you need to do is create your own Route objects. You don’t need to build anything else. You’re good to go!

  • Great write up! I’ve been tinkering with custom WP routing on a few projects lately and keep changing my mind on the best approach. It’s great to see the thought process of someone else tackling the problem. I appreciate how you interject with the ‘why’s’ of your design decisions as it helps give more context to your code.

    There are a couple projects out there that have WP routers in them that I have found helpful too:

    http://getherbert.com
    http://wplib.org

    I have had success implementing the Symfony router module and hooking it into similar places in WP. There are some docs on it that are really enlightening about all the ‘moving’ parts needed for a router:

    http://symfony.com/doc/current/create_framework/

    The Symfony modules offer a lot of great features out of the box like path variables, but I also like the simplicity and small footprint of your example.

    Your article though highlights I think the biggest challenge which is finding the best way to connect an outside route system (or any outside code) into the innards of WP. It’s really helpful to read your thoughtful decisions about which hooks you choose to use, as well as where adding your own hooks helps keep your code flexible and user-friendly to other plugins.

    I’m waiting for your book!

    • Thanks Josh!

      There’s a few routing libraries that I found during my research. You named most of them, but there’s also WP-Router:
      https://github.com/jbrinley/WP-Router

      I’m glad you enjoyed the design decisions. I’m actually working on a routing system that’s closer to the Symfony one. There’s other design problems that are beyond the scope of this article.

      For example, one big problem is that the rewrite API doesn’t distinguish between request types (e.g. GET /my-plugin vs POST /my-plugin). It just maps to paths. That means that using a route_variable isn’t sufficient to solve the problem of identifying the correct route. This is actually a problem that getherbert hasn’t solved either either (didn’t check the other libraries).

      This puts a lot more burden on the routing system. You need to handle a lot more situations and you need a lot more classes. It adds a lot of complexity. Maybe I’ll go back to it one day 🙂

      The book is SLOWLY on its way lol.

      • Better speed up that book then! I can’t wait to check it out.

        • I have something coming soon (next week I hope!) to tidy everyone. I’m still working on the best way to teach this. That’s why these articles are important! There are concepts, but there’s also application/design. Both go hand in hand.

          • Great!
            Make sure you provide best practice examples. Sometimes discussion about what’s wrong and what’s right leads up to more confusion. While it is good for obvious reasons, though, having a clear best practices to follow can sometimes make our life easier.

          • No, I understand that. The issue is that there isn’t always a “best answer”. There’s a spectrum with trade offs. The best example of that so far is this piece:
            https://carlalexander.ca/designing-class-wordpress-hooks/

          • I get that, but there is a best practice workflow, that you have, which is what I really want to know. I mean it’s nice to learn what’s wrong and what’s right, but I’d prefer reading more about what’s your workflow after years of experience, and what works.

          • That’s coming for sure. Some stuff is very soon. I have some other article ideas to discuss that too. That said, my workflow looks a lot like this article. The difference is I can do a lot of this in my head and faster 🙂

          • Agreed. There are always trade-offs. WordPress is a trade-off. Drupal is trade-off Symfony is a trade-off. But at some point in the maturity of the product / market 100 people each with their own version of trade-off becomes less effective than ten groups of ten with some sort of organized understanding of the matter(s) at hand, don’t ya think?

            Always great stuff Carl. Thanks for taking the time to share.

      • I haven’t come across WP-Router, I’ll have to check it out.

        I would love to see the routing system your working on when you feel it’s ready to share. There are a lot of contexts like the GET/POST issue where things get complex quickly. That’s one reason I decided to just use Symfony and not try to reinvent the wheel. Eventually though, I would like to use something more tailored to integrate with WP.

        I look forward to the book, but I have gotten a lot from reading your articles on the site.

        Keep it up!

  • Awesome sauce! Looking forward to your book!

  • Todi Adiyatmo

    Hi !
    Very cool code , what is the license of the code ? I’am thinking creating a routing plugin based on your code

    • Feel free to reuse it any way you like. I wrote about it for a reason 🙂