Excuse me, do you have a moment to talk about our Lord and Saviour, WP_Rewrite?
WP Rewrite is dark voodoo magic. I screwed up, so you don't have to!
You know that part of every web based application where you tell it what to do when a request comes in on a certain URL?
This is Express.js (a Node.js package)
app.get('/', function (req, res) {
res.send('root')
});
app.get('/about', function (req, res) {
res.send('about')
});
app.get('/users/:userId/books/:bookId', function (req, res) {
res.send(req.params)
});
// Route path: /users/:userId/books/:bookId
// Request URL: http://localhost:3000/users/34/books/8989
// req.params: { "userId": "34", "bookId": "8989" }
This is Laravel:
$router->group(['middleware' => 'web'], function ($router) {
// API Token Refresh...
$router->put( '/spark/token', 'TokenSecretController@refresh' );
// API Settings
$router->get( '/settings/api/tokens', 'TokenSecretController@all' );
$router->post( '/settings/api/token', 'TokenSecretController@store' );
$router->put( '/settings/api/token/{token_id}', 'TokenSecretController@update' );
$router->get( '/settings/api/token/abilities', 'TokenSecretAbilitiesController@all' );
$router->delete( '/settings/api/token/{token_id}', 'TokenSecretController@destroy' );
//
$router->get( '/logs/{token?}', 'LogController@index');
});
This is React (technically react-router):
const Main = () => (
<main>
<Switch>
<Route exact path='/' component={Home}/>
<Route path='/roster' component={Roster}/>
<Route path='/schedule' component={Schedule}/>
</Switch>
</main>
)
See? It’s super easy.
... and then there’s WordPress
WordPress and rewrites
It’s basically dark voodoo magic. The whole mapping of what should happen given URLs is stored in the database that is first fetched at some point, then parsed, then the URL is matched against it, and then the template loader (see this post about the template loader) decides what to do with it, but plugins / themes have a chance to change that, so you don’t really have one file you can just open and LOOK AT to figure out what will happen.
On top of that, the whole WP_Rewrite
object is full of arrays of arrays with keys of regex and values of MORE regex. I’m not kidding, look at this:
Also some of these might overwrite others, some have higher priority than others, and there’s no telling where each of those came from.
To add a rewrite rule, you need to touch WordPress in 4 different places:
- add an endpoint (
add_endpoint
/add_rewrite_endpoint
/ ? / ¯\_(ツ)_/¯) - add a rewrite rule (
add_rewrite_rule
/add_rewrite_tag
/ ? / ¯\_(ツ)_/¯ maybe?) - add a query var (filter the global variable with
query_vars
) - and flush the rewrite rules to move these additions into the database
Oh, by the way, the query vars need to be declared for every request.
But what can this do?
I was debugging a fairly weird issue that I couldn’t pin down for a while. Initially I was at a loss. The plugin hooks into WooCommerce, and basically adds another endpoint. I have trailing slashes on my site (oh, those count too! Guess where THAT’s configured? Stay tuned for the grand reveal at the end).
When I save the settings, here’s what would happen:
- save setting
- go to
/permalink-that-should-work/
, get 404 (redirect to front page) - try again on
/permalink-that-should-work/
, succeed. What
Or
- save setting
- go to
/permalink-that-should-work
(no trailing slash), totally expecting it to fail, because the above did too - succeed
But I was flushing the rewrite rules! HARD! (flush_rewrite_rules( true )
).
Why?
Because the order of things was all wrong I didn’t know enough about permalinks, and it took me 7 hours of digging into all of WordPress to solve it. Ideally you’d want to add your own permalink rules before flushing the rules.
When you save settings in a WooCommerce tab (to which my plugin attached its settings page), it calls WC_Admin_Settings::save
. In that method there are a few hooks available, one of them being ’woocommerce_update_options_’ . $current_tab
. That’s where I hook in to save my plugin’s data, and flush the rewrite rules.
This save
method is fired in the settings_page_init
method on WC_Admin_Menus
class, which it adds to the hook fired when loading the settings page.
The hook firing when loading the settings page happens in wp-admin/admin.php
line 212 (in 4.8.1): do_action( "load-{$page_hook}" );
$page_hook
is the result of a function call to get_plugin_page_hook
.
That depends on get_plugin_page_hookname
, which depends on get_admin_page_parent
, and then does a bit of regex...
All of THAT runs because we’re literally hitting the wp-admin/admin.php
file.
Which loads wp-load.php
as one of the first things, so settings and early hooks will run.
However all of the above runs before any of the other code that’s responsible for register my new rewrite rule, so they just linger, unsaved.
WooCommerce schedules a single event for now
to flush the rewrite rules.
WordPress cron jobs run on init
though (see default-filters.php
file).
Order of operations
Which also means that this is the order of important operations here when I click the "Save settings" button:
wp-admin/admin.php
gets called../wp-load.php
gets requiredwp-settings.php
runs which requireswp-includes/functions.php
and then checks whether WP is installed, and options get autoloaded there, from what I can see, the earliest. Rewrite rules are autoloaded. This is the data that’s going to be used from then onplugins_loaded
runs. My plugin would normally add the rules here, but this is an admin request, so I didn’t include itinit
runs (and thus WP Cron gets called / done)- admin page hook gets called
WC_Admin_Settings::save
invoked- settings are saved
- a
flush_rewrite_rules()
ran from my plugin in the admin - that causes the rewrite rules to reset to empty, and rebuild from scratch. My plugin’s routes are not here because the files aren’t included, because this is an admin request and I only include them if
! is_admin()
WC()->query->init_query_vars
called which sets WC’s query varsWC()->query->add_endpoints
called, which adds endpoints for WC query vars- the
woocommerce_flush_rewrite_rules
action is scheduled to run next time Cron runs oninit
- rest of the request
1. New page, loading admin
wp-settings.php
runs which requireswp-includes/functions.php
and then checks whether WP is installed, and options get autoloaded there, from what I can see, the earliest. Rewrite rules are autoloaded. This is the data that’s going to be used from then onplugins_loaded
runs. My plugin would normally add the rules here, but this is an admin request, so I didn’t include itinit
runs, Cron triggers, spawning a new thread with it- on the new Cron thread
plugins_loaded
runs again, and because Cron is not an admin request,is_admin()
is false, my files get included, and thus the query vars added - Cron triggers the previously scheduled
woocommerce_flush_rewrite_rules
action - that triggers
flush_rewrite_rules
, and because my query vars and endpoints have been added, my permalink works
2. New page, loading my permalink WITH the trailing /
Assuming we’re right after having saved the option.
wp-settings.php
runs which requireswp-includes/functions.php
and then checks whether WP is installed, and options get autoloaded there, from what I can see, the earliest. Rewrite rules are autoloaded. This is the data that’s going to be used from then onplugins_loaded
runs, my plugins adds the QV and endpointsinit
runs, Cron spawned, separate thread, not relevantparse_request
runs, the rewrite rules are fetched from the options table. Since the last time we saved it we were in the admin area, my permalinks aren’t there. The Cron spawned, but it’s a separate thread, it’s non-blocking, and it’s not updating my request- I get redirected to the front page
- The next request to the same URL WILL have my permalinks in it, because the flush rewrite rules attached to the Cron would have updated it
3. New page, loading my permalink WITHOUT the trailing /
Mostly the same as above, except in step 4, instead of being redirected to the front page, I get redirected to the same page but WITH the trailing slash:
https://foobar.dev/some-page
-> https://foobar.dev/some-page/
The one with the trailing slash is another request, by which time Cron kicked off on the page without the trailing slash would have done its thing.
Fixing it
Two possible approaches:
- I just need to remove the call to
flush_rewrite_rules
from within my plugin when the settings are saved. That way the previous permalinks are still there, so mine aren’t wiped, or - I include the files responsible for setting up the permalinks on the admin side too. Otherwise how would WP know about those settings when spamming the "Save Permalinks" button?
I’ve gone with the latter.
P.S.: whether you have trailing slashes on ALL your links or not depends on what you set here:
Photo by Jason Leung from Unsplash