Decoupled routing beyond permalinks
Break free of WordPress permalink limits with clean, decoupled custom routes.
My name is Robin, I’m a freelance WordPress developer and I often have projects that require complex non-standard URLs. Relying on WordPress’ internal routing and permalink structure can lead to problems.
Looking for the solution? Jump straight to Upstatement/routes!
WordPress has grown a lot since its creation in 2003. Starting out as a simple blogging platform, it rapidly grew into the incredible software platform we know today.
Originally, WordPress’ permalink structure was rooted in the idea of categories, archives, and simple blog posts.
As the platform grew and diversified, accommodating everything from e-commerce sites to portfolios, the need for a more flexible and sophisticated routing system became evident. But it was still difficult to work around WordPress rewrite rules and their stubborn complexity.
In the very early days, WordPress URLs were straightforward. They often looked something like this:
http://example.com/?p=123
This structure mapped the URL in your browser directly to a database query.
For instance, ?p=123 here would be fetching the post with the ID of 123. Other parameters could be added, for
example ?p=123&post_type=post&s=keyword
But this approach is very limited, and leaves room open for
As the platform matured, “pretty permalinks” were introduced, allowing URLs to be more human-readable and SEO-friendly, like so
http://example.com/2023/09/my-awesome-blog-post/
However, these pretty permalinks were still really limited. They were based on a set of predefined structures, primarily revolving around dates, post names, and categories. Customising them or adding in new URL structures became difficult. WordPress rewrite rules were still a limiting factor.
Imagine you’re setting up an online shop site with WordPress (forget that WooCommerce exists for a minute – let’s go back to basics!)
You want to run a promo where each product category has its own sale day:
http://example.com/sale/shoes/
http://example.com/sale/jackets/
Using traditional WordPress rewrite rules, achieving this structure would be a bit of a nightmare. You’d likely have to create a “sale” category and then add sub-categories like “shoes” and “jackets”. But what if “shoes” already exists as a primary category? The structure becomes messy and conflicts can arise.
Furthermore, what if you wanted to add a date to the sale, like:
http://example.com/sale/2023-09-16/shoes/
Using traditional WordPress permalinks, you’d face several challenges:
http://example.com/sale/2023-09-20/shoes. Standard permalinks don’t easily allow for such dynamic date integrations within the URL.(If you’d like to see an example of the code required to map out something like this in WordPress, check out the end of the article)
To understand the power and importance of good permalink structures, let’s look at some examples from other sites:
As developers tried to get more control over their URLs with WordPress rewrite rules, particularly if they were digging into complex nested URL routes, the need for more advanced routing solutions arose.
During a project which required some particularly complicated routing config, I discovered a really cool project – https://github.com/Upstatement/routes
Upstatement/routes is designed for use with Timber, a flexible WordPress theme builder, but can be used as a standalone helper.
Heads up Upstatement/routes is effectively unmaintained — it hasn’t kept pace
with Timber 2.x and I wouldn’t reach for it on a new build today. The technique is still sound; just use a maintained
tool. For most cases the WordPress Rewrite API (add_rewrite_rule + query_vars,
as in the appendix) is enough. For clean, decoupled routing, register real endpoints with
register_rest_route(), or drop a mature router like nikic/fast-route in front of your own
front-controller. Also swap wp_redirect() for wp_safe_redirect() in the samples below.
Upstatement allows for developers to bypass the WordPress rewrite rules and
This gives a lot more control over a project’s URL routes – especially if WordPress rewrite rules are giving you a headache, then installing Upstatement/routes could seriously help!
All you need to do is install it via Composer and ensure you’ve added
require 'vendor/autoload.php'; to the top of your functions.php
composer require upstatement/routes
You’re then good to start implementing the routing functionality. Here’s a very basic example:
Routes::map('my-custom-page', function($params) {
$myvar = "Hello";
Routes::load('custom-template.php', ['myvar' => $myvar]);
});
This should then show you the contents of custom-template.php (assuming it exists within your theme folder)
when you visit /my-custom-page in the browser.
Upstatement aims to replicate the way many modern PHP frameworks handle routing.
In the early days of the web, the browser’s URL path was directly mapped to the server’s file system, reflecting the exact files and folders on disk. This meant that the URL’s structure was often tied to the physical structure of the application’s files.
In time, URL rewriting became the norm, though there was still some connection between the URL and the physical disk or database structure. The default WordPress rewrite rules continued down that road, maintaining some degree of coupling between URL and posts/categories/archives.
However, modern frameworks have moved away from this approach. Instead, they use a style of routing that decouples the URL path from the filesystem. Frameworks such as Zend or Laravel deliver very clean and precise routing systems.
2026 update The Zend Framework was rebranded as Laminas back in 2019 — if you go looking, that’s the maintained successor. Laravel remains the go-to for expressive, decoupled routing.
Imagine you’re the owner of a thriving e-commerce platform. As part of your marketing strategy, you decide to run a series of promotional events for different product categories. Each promo is unique, with some being time-sensitive, while others are evergreen.
Maybe you’ve already taken a look at WordPress rewrite rules syntax and found it lacking (and confusing!)
With tools like Upstatement’s router, we could build out our URL strategy like so
sale/:date/:category. This allows for dynamic generation of URLs based on the date and category, making it easy to set up and manage multiple promos.sale/2023-09-20/shoes/ and the primary category shoes/ can coexist without issues.// easily define the URL path you want with a clean syntax
Routes::map('flash-sale/:category', function($params){
// some additional internal logic
$currentTime = time();
$saleStartTime = strtotime("today 2:00 PM");
$saleEndTime = strtotime("today 5:00 PM");
if ($currentTime < $saleStartTime || $currentTime > $saleEndTime) {
// If outside the flash sale window, redirect to a teaser page
wp_redirect(home_url('/flash-sale-teaser/'));
exit;
}
// specify the Loop query to use within the fallback template
$query = 'category_name=' . $params['category'] . '&orderby=rand&posts_per_page=5';
Routes::load('flash-sale.php', null, $query);
});
In this code,
Such dynamic and time-sensitive routes might be one way to significantly enhance user engagement. Customers might frequently check the site around potential flash sale times, increasing traffic and potential sales.
This is something that would be tough to throw together quickly using WordPress rewrite rules out-of-the-box.
So you can see how this flexible, fluid approach lets you really open up the possibilities when it comes to WordPress routing and template design!
Hopefully this should have given you a decent insight into how you can use custom routes to make dealing with URLs, permalinks and WordPress rewrite rules a bit less of a nightmare!
Here’s what the code to implement the e-commerce example URLs might look like with the default WordPress rewrite rules syntax. You can see how it’s
function custom_sale_rewrite_rule() {
// Add a rewrite rule for the sale structure
add_rewrite_rule(
'^sale/([0-9]{4}-[0-9]{2}-[0-9]{2})/([^/]+)/?$',
'index.php?sale_date=$matches[1]&sale_category=$matches[2]',
'top'
);
// Register custom query vars for the sale date and category
add_filter('query_vars', function($vars) {
$vars[] = 'sale_date';
$vars[] = 'sale_category';
return $vars;
});
}
add_action('init', 'custom_sale_rewrite_rule');
function handle_sale_request() {
if (get_query_var('sale_date') && get_query_var('sale_category')) {
// Fetch the products based on the sale date and category
// This would require additional logic to determine which products are on sale
$args = array(
'post_type' => 'product',
'tax_query' => array(
array(
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => get_query_var('sale_category'),
),
),
);
$query = new WP_Query($args);
// Load a custom template to display the sale products
include 'path-to-your-sale-template.php';
exit;
}
}
add_action('template_redirect', 'handle_sale_request');
All articles
I help content-rich organisations understand their data and pull it back into one place. Tell me what specific problems you're having and I'll be happy to talk solutions with you.
Tell me what specific problems you’re having and I’ll be happy to talk solutions with you.