I want ot give you an "alternate" approach. I'm pretty sure you're not going to follow this, but I thinks is interesting to read.
OOP "routing" approach
In WordPress, "pretty" urls are mathed to "ugly" urls.
But most web frameworks (not only PHP) uses the concept of "routing": to match an url to an "action" (or a controller).
I want to give you an idea how to apply this rotuing technique to WordPress, using an OOP approach.
The interface
First of all, we will write an interface to make clear what a route object should do:
namespace MySite;
interface RouteInterface
{
/**
* Returns true when the route matches for the given url
*
* @return bool
*/
public function matched();
/**
* Returns the WP_Query variables connected to the route for current url.
* Should be empty if route not matched
*
* @return array
*/
public function getQueryArgs();
/**
* Return the url for the route.
* Variable parts of the url should be provided via $args param.
*
* @param array $args
* @return string
*/
public function getUrl(array $args = []);
}
Very simple.
Pleasee read the documentation blocks for details.
The url object
To "match" an url, we need first to know it. WordPress does not provida a function or am methods for that.
add_query_arg()
, when used passing an empty array, is close enough.
However, when WordPress is installed in a sub folder the subfolder path is also returned.., we should remove it, because it is not part of the url we want to match.
Let's write an object for the scope.
namespace MySite;
class WordPressUri
{
public function getUrl()
{
$url = trim(esc_url_raw(add_query_arg([])), '/');
// check if wp is in a sub folder
$homePath = trim(parse_url(home_url(), PHP_URL_PATH), '/');
// remove WordPress subfolder if any
if ($homePath) {
$url = preg_replace('~^('.preg_quote($homePath, '~').'){1}~', '', $url);
}
return trim($url, '/');
}
}
Quite simple again, I think.
The concrete route object
Now, we have everything to start writing the concrete route object that implements the route interface.
namespace MySite;
final class MovieReviewRoute implements RouteInterface {
const REGEX = '^movie-review/([\w]+)-([0-9]{2})([0-9]{2})([0-9]{4})$';
private $uri;
private $postname = '';
public function __construct(WordPressUri $uri = null) {
$this->uri = $uri ? : new WordPressUri();
}
public function matched() {
$matches = [];
if (preg_match(self::REGEX, $this->uri->getUrl(), $matches) !== 1) {
return false;
}
list(, , $day, $month, $year) = array_map('intval', $matches);
if (checkdate($month, $day, $year)) {
$this->postname = $matches[1];
return true;
}
return false;
}
public function getQueryArgs() {
return $this->postname ? ['name' => $this->postname] : [];
}
public function getUrl(array $args = []) {
// check if postname was given, or as alternative a post object / post id
$post = empty($args['post']) ? '' : get_post($args['post']);
$postname = empty($args['postname']) ? '' : $args['postname'];
if ( ! $postname && $post instanceof \WP_Post) {
$postname = $post->post_name;
}
// if no date given, use post date if post was given, or just today
if (empty($args['date'])) {
$timestamp = $post instanceof \WP_Post
? strtotime($post->post_date)
: current_time('timestamp');
$args['date'] = date('dmY', $timestamp);
}
return home_url("movie-review/{$postname}-{$args['date']}");
}
}
Sorry if is a lot of code in one place, but it doesn't do nothing "special".
It just do what the interfaces dictates, for the specific case.
Some details on the methods:
matched()
uses a regex to match the url against the wanted format. It also check that the matched date is a valid date. In the process, it saves the postname
object variable, so it can be used to know which is the post that matched.
getQueryArgs()
just returns the query var "name", that is enough to find a post
getUrl()
combines the array of arguments given, to create an url that would match the route. Just what interface wants.
The right hook
We are almost done. We have all the objects, now we need to use them. The first thing we need is an hook, to intercept the request.
The right place is 'do_parse_request'.
Returning false on that hook, we can prevent WordPress to parse the url using rewrite rules. Moreover, the hook passes as second argument an instance of WP
class: we can use it to set the query variables we need, and that our route can provide when matched.
In action
The code we need to match the route:
namespace MySite;
add_filter('do_parse_request', function ($bool, \WP $wp) {
$movieRoute = new MovieReviewRoute();
// if route matched, let's set query vars and stop WP to parse rules
if ($movieRoute->matched()) {
$wp->query_vars = $movieRoute->getQueryArgs();
$wp->matched_rule = MovieReviewRoute::REGEX;
// avoid WordPress to apply canonical redirect
remove_action('template_redirect', 'redirect_canonical');
// returning false WP will not parse the url
return false;
}
return $bool;
}, 10, 2);
I think that it should be pretty easy to understand.
Two things to note:
I removed "redirect_canonical" function from 'template_redirect'
otherwise WordPress (that knows nothing about our routes) can redirect the post to its "canonical" url, that is the one that is set in standard permalink structure.
I set $wp->matched_rule
to something predictable: this allows us to know when the we set query arguments via our route object.
Generating urls
We have the route working, but we need to send users to our route. So we need to filter permalinks. The rote object has a method that generates the url, that we can leverage for the scope.
namespace MySite;
add_filter('post_link', function ($permalink, \WP_Post $post) {
if (has_category('movie-review', $post)) {
$movieRoute = new MovieReviewRoute();
$permalink = $movieRoute->getUrl(['post' => $post]);
}
return $permalink;
}, 10, 2);
With this code, any post that has the "movie-review" category will get the url that matches our route.
Final touches
At the moment, the post in "movie-review" category can be viewed with 2 different urls, the standard one, and the one the matches our route.
We should prevent that, it is very bad for SEO, among other things.
namespace MySite;
add_action('template_redirect', function() {
if (
is_single()
&& has_category('movie-review', get_queried_object())
&& $GLOBALS['wp']->matched_rule !== MovieReviewRoute::REGEX
) {
$movieRoute = new MovieReviewRoute();
wp_redirect($movieRoute->getUrl(['post' => get_queried_object()]), 301);
exit();
}
});
Thanks to the variable we set on WP
class when our route matches, we are able to recognize when a movie review post is viewed with standard url.
If that happen, we just redirect to the route url.
Notes
The main question is: "Does it worth the trouble"? I can say that this approach does not suffer of any problems you have with the rewrite rule approach.
For example, you can freely change the post slug in admin interface, and still have 2 different url structure for post in that specific category.
Of course is more code than just 2 functions hooked into 2 hooks. And more code, is never a good thing.
However, you should note that after you put this method in place, adding more and more routes is much easier, you just need to write a class, because the interface and the matching "mechanism" are already there.
So, to answer the question: probably no: for just one route it probably doesn't worth the trouble, but if you have a few, maybe it does.
Consider that out there exist cool libraries you can integrate in this approach to make the matching mechanism much easier and more powerful.
Gotcha
The code is completely untested. And it requires PHP 5.4+ because of short array syntax.
post
type for both is that you can't have two completely different structures for one type and have an editable slug on the post edit screen.hateful-eight
|hateful-eight-1
|hateful-eight-2
etc. A post type would be so much more beneficial as Milo says.