3

I am building a single author blog that will include multiple categories. One main category is "Movie Reviews."

The author will leave a short review every time they watch a movie - even if they've seen the movie in the past and reviewed it on their site in the past.

For the "Movie Reviews" category (only this category), I need to setup a permalink structure such as:

  • /%category%/%postname%-%day%%monthnum%%year%/
  • /movie-review/the-hateful-eight-01192016/

This will give a unique URL to each review of the same movie.

The rest of the categories will simply use /%postname%/

I am 99% positive that I did this exact thing a few years ago, but that site is no longer active, I don't have anything about the process in my notes, and I cannot seem to find any direction via Google search, WordPress.org forums, or WordPress Answers.

  • 4
    using a custom post type for movie reviews might be a little easier. – Milo Jan 24 '16 at 18:50
  • 4
    The problem with using the built in 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. – Milo Jan 24 '16 at 19:46
  • I mean, just using a custom post type, no matter how many times a post has the same name it will just auto increment: hateful-eight | hateful-eight-1 | hateful-eight-2 etc. A post type would be so much more beneficial as Milo says. – Howdy_McGee Jan 27 '16 at 4:28
  • No unnecessary handwork required. Just use this Custom Permalink Plugin. Working fine for me. – Ariful Haque May 4 '17 at 16:02
6
+125

I am not sure if this is the best solution or not, but it works:

function movie_review_permalink( $url, $post, $leavename ) {
    $category = get_the_category($post->ID); 
    if (  !empty($category) && $category[0]->slug == "test" ) { //change 'test' to your category slug
        $date=date_create($post->post_date);
        $my_date = date_format($date,"dmY");
        $url= trailingslashit( home_url('/'. $category[0]->slug .'/'. $post->post_name .'-'. $my_date .'/' ) );
    }
    return $url;
}
add_filter( 'post_link', 'movie_review_permalink', 10, 3 );

Above code will make your post permalink for category test to http://wpHomeURL/test/post-name-ddmmyyyy structure.

Now you will need to add rewrite rule o make this work.

function movie_review_rewrite_rules( $wp_rewrite ) {
    $new_rules['^test/([^/]+)-([0-9]+)/?'] = 'index.php?name=$matches[1]'; //change 'test' to your category slug
    $wp_rewrite->rules = $new_rules + $wp_rewrite->rules;
    return $wp_rewrite;
}
add_action('generate_rewrite_rules', 'movie_review_rewrite_rules');

Hope this helps!

  • 1
    '^test/([^/]+)-([0-9]+)/?' Are you sure that this regex will work? It's currently hardcoded for test and I guess it's not supposed to be. Or at least it should be movie-review. – denis.stoyanov Jan 27 '16 at 7:23
  • 1
    Yes, this regex works, but when category slug is test. That's why I have added comment to change test to his category slug. – тнє Sufi Jan 27 '16 at 9:38
6

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.

  • Great and detailed answer. – bueltge Jan 30 '16 at 12:00

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.