Adding dates to custom post permalinks in WordPress

Making date-based permalinks for custom posts in WordPress

If you didn’t know already, this blog is not the only thing that we work on — we also take on clients to work on website and web-related projects for them. Recently, we worked on a website project where we had to code a custom WordPress post type into the custom theme we made for the client (yes we make those).

This custom post type (which shall henceforth be called Articles — what we named the post type) was supposed to serve a purpose similar to the default WordPress Post — it was meant to go into a blog section for the website, and the client wanted to be able to assign categories to individual articles. All of this is pretty standard fare when it comes to WordPress customisation, as you can easily figure out how to do it reading official guides and documentation from WordPress:

We needed something a bit more though, as we wanted to customise the permalinks (i.e. auto-generated URL) of our Articles such that they are:

  1. Preceeded by the article slug, e.g. example.com/article/my-article-title
  2. Display the year and month before the post title, e.g. example.com/article/2021/03/my-article-title
  3. Display a list of articles posted on the specified year and month if it was specified in the URL, e.g. example.com/article/2021/03 would show all the articles posted in March 2021.

Want to find out how we did it? Then continue reading.

If you don’t want to go through the hassle of coding your custom post type permalinks, you can easily change the permalinks of custom post types with this plugin. This doesn’t give you the same amount of control as manually coding it does (you can’t change the slug preceding your post, for instance), but it is not a bad option if you’re just looking to edit how the post title section of your permalink is shown.

Registering the post type

For starters, here’s our code for registering the post type. We put it inside a class to make the code more modular:

class-Article.php

<?php defined('ABSPATH') or die("This script can't do much by its own.");

class Post_Article {

	// Slug for our post type.
	private static $slug = 'hsp-article';

	// Arguments for registering the post type.
	private static $attributes = array(
		'labels' => array(
         		'name' => 'Articles', 'singular_name' => 'Article', 'add_new_item' => 'Add New Article',
         		'edit_item' => 'Edit Article', 'new_item' => 'New Article',
         		'view_item' => 'View Article', 'view_items' => 'View Articless',
         		'search_items' => 'Search Articles', 'not_found' => 'No Articles found',
         		'not_found_in_trash' => 'No Articles found in Trash',
         		'parent_item_colon' => 'Parent Article',
         		'all_items' => 'All Articles', 'archives' => 'Article Archives',
         		'attributes' => 'Article Attributes', 'insert_into_item' => 'Insert into Article',
         		'add_new' => 'Add New Article' 
     		),
		'public' => true, 
		'has_archive' => true, 
		'hierarchical' => false,
		'supports' => array(
			'title',
			'editor', 
			'thumbnail',
			'excerpt',
		),
		'menu_position' => 5,
		'menu_icon' => 'dashicons-media-document'
	);

	// Registers our post with the arguments in the class.
	public static function init() {
		register_post_type(self::$slug, self::$attributes);
	}
}

Post_Article::init(); // Initialises the class to register our post type.

By default, this makes our articles have permalinks with the following structure:

example.com/hsp-article/post-title


Article continues after the advertisement:


Modifying the permalink slug

Since we wanted our permalink to have article as the preceding segment (instead of hsp-article), we add a rewrite parameter into the our post type attributes:

class-Article.php

<?php defined('ABSPATH') or die("This script can't do much by its own.");

class Post_Article {

	// Slug for our post type.
	private static $slug = 'hsp-article';

	// Arguments for registering the post type.
	private static $attributes = array(
		'labels' => array(
         		'name' => 'Articles', 'singular_name' => 'Article', 'add_new_item' => 'Add New Article',
         		'edit_item' => 'Edit Article', 'new_item' => 'New Article',
         		'view_item' => 'View Article', 'view_items' => 'View Articless',
         		'search_items' => 'Search Articles', 'not_found' => 'No Articles found',
         		'not_found_in_trash' => 'No Articles found in Trash',
         		'parent_item_colon' => 'Parent Article',
         		'all_items' => 'All Articles', 'archives' => 'Article Archives',
         		'attributes' => 'Article Attributes', 'insert_into_item' => 'Insert into Article',
         		'add_new' => 'Add New Article' 
     		),
		'public' => true, 
		'has_archive' => true, 
		'hierarchical' => false,
		'supports' => array(
			'title',
			'editor', 
			'thumbnail',
			'excerpt',
		),
		'menu_position' => 5,
		'menu_icon' => 'dashicons-media-document',
		'rewrite' => array('slug' => 'article','with_front' => false)
	);

	// Registers our post with the arguments in the class.
	public static function init() {
		register_post_type(self::$slug, self::$attributes);
	}
}

Post_Article::init(); // Initialises the class to register our post type.

The newly-added line changes our article permalinks to the structure we want. Note that the with_front sub-option inside rewrite is necessary, becase without it, the article slug will just be precedingly added to the existing permalink like so:

example.com/article/hsp-article/post-title

You might be wondering why we didn’t name the post type’s slug as article instead of hsp-article. We did it because article is a very common name, and we didn’t want the post type’s slug to conflict with plugins that may introduce custom post types that have the same slug.

Adding year and month into the permalink

This is where things get a little comlex. For starters, let’s add WordPress Structure Tags into our rewrite parameter:

'rewrite' => array('slug' => 'article/%year%/%monthnum%','with_front' => false)

This isn’t enough though, because if you view any of your posts, you’ll find that the URL literally contains the Structure Tags.

Structure tags in our URL
Our URL was: example.com/article/%year%/%monthnum%/post-name

This is because WordPress does not process Structure Tags for custom post types. We will need to hook a filter to post_type_link to replace the tags ourselves.

class-Article.php

<?php defined('ABSPATH') or die("This script can't do much by its own.");

class Post_Article {

	// Slug for our post type.
	private static $slug = 'hsp-article';

	// Arguments for registering the post type.
	private static $attributes = array(
		'labels' => array(
         		'name' => 'Articles', 'singular_name' => 'Article', 'add_new_item' => 'Add New Article',
         		'edit_item' => 'Edit Article', 'new_item' => 'New Article',
         		'view_item' => 'View Article', 'view_items' => 'View Articless',
         		'search_items' => 'Search Articles', 'not_found' => 'No Articles found',
         		'not_found_in_trash' => 'No Articles found in Trash',
         		'parent_item_colon' => 'Parent Article',
         		'all_items' => 'All Articles', 'archives' => 'Article Archives',
         		'attributes' => 'Article Attributes', 'insert_into_item' => 'Insert into Article',
         		'add_new' => 'Add New Article' 
     		),
		'public' => true, 
		'has_archive' => true, 
		'hierarchical' => false,
		'supports' => array(
			'title',
			'editor', 
			'thumbnail',
			'excerpt',
		),
		'menu_position' => 5,
		'menu_icon' => 'dashicons-media-document',
		'rewrite' => array('slug' => 'article/%year%/%monthnum%','with_front' => false)
	);

	// Registers our post with the arguments in the class.
	public static function init() {
		register_post_type(self::$slug, self::$attributes);
		add_filter( 'post_type_link', array(__CLASS__, 'post_type_link'), 10, 2 );
	}

	// Converts the Structure Tags in our permalink.
	public static function post_type_link($url, $post) {
		if ( self::$slug == get_post_type($post) ) {
			$url = str_replace( "%year%", get_the_date('Y'), $url );
			$url = str_replace( "%monthnum%", get_the_date('m'), $url );
		}
		return $url;
	}
}

Post_Article::init(); // Initialises the class to register our post type.

By hooking a function of ours to the post_type_link filter, our year and month Structure Tags are replaced with the actual year and month our article was posted on.

Of course, you are not restricted to just adding the year and month onto your permalinks. You can set up any URL based on the metadata of your posts, but you’ll need to add on to the code we provide above (as we only replace the year and month tags).


Article continues after the advertisement:


Adding date-based article archives

Finally, remember that we also wanted our site to list all of our articles if we only list the year and month in the URL, like so?

example.com/article/2021/03

In WordPress, what we want to do is normally done by adding a GET query string to the URL, like so:

example.com?post_type=hsp-article&year=2021

To get our desired custom post type to show all posts using the format that we want, we need to add a rewrite rule that redirects our desired URLs to the URL above. This is, however, significantly trickier than everything we did before as the conditions we are looking for are more complex. We want the URL of any year and month to be affected by this rule.

Year4 digit numbers right after the article/ section
Month1 or 2 digit numbers right after the %year%/ section
The patterns we will need to look out for.

To do this, we will need to formulate Regular Expressions (i.e. RegEx, or regex) to check if our URL matches these conditions. Then, we need to register these regexes with the add_rewrite_rule() function in WordPress.

The add_rewrite_rule() function takes 3 parameters:

  1. The regex
  2. The corresponding query that will be rewritten
  3. Priority of the rule

Don’t know what regex is? Think of it as a more dynamic version of the str_replace() method (which we did earlier) — instead of being able to only replace a single pattern of text, regexes are able to match or replace a multitude of patterns. Due to its flexibility and power, however, regexes can be quite complex syntactically. For an in-depth crash course on its syntax, you can check out this 20 minute video.

Formulating the regex

Again, we are trying to get our regex to match URLs like this one:

example.com/article/2021/03

We are going to use some regex metacharacters to help us match our desired URLs. This is a table describing the function of these metacharacters:

CharacterDescriptionExampleExplanation of example
^Matches the beginning of a string^articleMatched string must start with article.
$Matches the end of a stringarticle$Matched string must end with article.
?The preceding character is optionalarticle/?Matched string can either be article or article/
[…]Matches any character contained in the set of brackets[0-9]Matches a single character between 0 to 9.
{x} The number of times something should be matched[0-9]{4}Matches 4 characters between 0 to 9.
{x,y}The (range of) the number of times something should be matched.[0-9]{1,2}Matches 1 or 2 characters between 0 to 9.

Based on these characters, we can make a regex that would match article/2021/03, and the regex looks like this:

^article/[0-9]{4}/[0-9]{1,2}/?$

In short, the regex captures strings that:

  1. Must start with the characters article/;
  2. Followed by 4 digits (i.e. 0 to 9) and a /;
  3. Then, by another 1 to 2 digits, and an optional /;
  4. And there should be nothing else after (3)

We also want URLs with only the year and not the month to show all articles in the year, so we formulate another regex for that:

^article/[0-9]{4}/?$

This is very similar to the first regex, except that it matches strings with only the year segment.

Adding the rewrite rules

Now that we have our regexes, it’s time to insert them into our script using add_rewrite_rule().

class-Article.php

<?php defined('ABSPATH') or die("This script can't do much by its own.");

class Post_Article {

	// Slug for our post type.
	private static $slug = 'hsp-article';

	// Arguments for registering the post type.
	private static $attributes = array(
		'labels' => array(
         		'name' => 'Articles', 'singular_name' => 'Article', 'add_new_item' => 'Add New Article',
         		'edit_item' => 'Edit Article', 'new_item' => 'New Article',
         		'view_item' => 'View Article', 'view_items' => 'View Articless',
         		'search_items' => 'Search Articles', 'not_found' => 'No Articles found',
         		'not_found_in_trash' => 'No Articles found in Trash',
         		'parent_item_colon' => 'Parent Article',
         		'all_items' => 'All Articles', 'archives' => 'Article Archives',
         		'attributes' => 'Article Attributes', 'insert_into_item' => 'Insert into Article',
         		'add_new' => 'Add New Article' 
     		),
		'public' => true, 
		'has_archive' => true, 
		'hierarchical' => false,
		'supports' => array(
			'title',
			'editor', 
			'thumbnail',
			'excerpt',
		),
		'menu_position' => 5,
		'menu_icon' => 'dashicons-media-document',
		'rewrite' => array('slug' => 'article/%year%/%monthnum%','with_front' => false)
	);

	// Registers our post with the arguments in the class.
	public static function init() {
		register_post_type(self::$slug, self::$attributes);
		add_filter( 'post_type_link', array(__CLASS__, 'post_type_link'), 10, 2 );

		// Adds the rewrite rule to capture URLs with year and month.
		add_rewrite_rule(
			'^article/([0-9]{4})/([0-9]{1,2})/?$',
			'index.php?post_type=hsp-article&year=$matches[1]&monthnum=$matches[2]',
			'top'
		);

		// Adds the rewrite rule to capture URLs with year only.
		add_rewrite_rule(
 			'^article/([0-9]{4})/?$',
 			'index.php?post_type=hsp-article&year=$matches[1]',
 			'top'
		);
	}

	// Converts the Structure Tags in our permalink.
	public static function post_type_link($url, $post) {
		if ( self::$slug == get_post_type($post) ) {
			$url = str_replace( "%year%", get_the_date('Y'), $url );
			$url = str_replace( "%monthnum%", get_the_date('m'), $url );
		}
		return $url;
	}
}

Post_Article::init(); // Initialises the class to register our post type.

Notice that in our code, we’ve also added parentheses (i.e. brackets) around the numeric parts of our regexes:

^article/([0-9]{4})/([0-9]{1,2})/?

^article/([0-9]{4})/?$

These parentheses denote capture groups, which are used in the 2nd argument in our add_rewrite_rule() call. Capture groups allow us to capture characters in a specific part of our regex and reuse them in another context. In this case, the capture groups are used to replace the sections in our 2nd argument denoted by $matches.

index.php?post_type=hsp-article&year=$matches[1]&monthnum=$matches[2]

index.php?post_type=hsp-article&year=$matches[1]

The 3rd argument in add_rewrite_rule() simply denotes the priority of the rewrite rule we have added. It is optional, so if you don’t specify it, the default value will be bottom.

Conclusion (and how to use our code)

If you’ve TL;DR-ed the rest of this article and scrolled down here just to find the code, it’s on the section right above.

To use the class-Article.php file that we’ve provided, you need to save it into a folder in your theme or plugin, then hook it to the init action on WordPress. Assuming you’ve put our file in a folder called inc (that’s where we put it in our theme) in your theme or plugin, here is how you will call it:

functions.php

// If you want to add this to your theme,
// add the following to your theme's functions.php.
function article_post_init() {
	require_once 'inc/class-Article.php';
}
add_action('init','article_post_init');

your-plugin.php

// If you want to add this to your plugin,
// add the following to your plugin's main PHP file
function article_post_init() {
	require_once 'inc/class-Article.php';
}
add_action('init','article_post_init');

If you can’t view your custom post on the front-end after implementing our code, do remember to flush your permalinks! Otherwise, the permalinks that your custom post displays will lead to a 404 page.

As usual, if you find any errors in our code, or find ways our code can be written better, do let us know about it in the comments!


Article continues after the advertisement:


Leave a Reply

Your email address will not be published. Required fields are marked *

Note: You can use Markdown to format your comments.

For security, use of Google's reCAPTCHA service is required which is subject to the Google Privacy Policy and Terms of Use.

I agree to these terms.

This site uses Akismet to reduce spam. Learn how your comment data is processed.