In making a site with non-standard WordPress pages, I ran into a common problem. How do I make a page that calls the data? To put it in a different way, I needed a URL (say, domain.com/stats/) to show site statistics. A ‘virtual page’ if you will.

The Easy Way

The first way I did this was the ‘easy’ way, though I’m loathe to really call it that. I made a page in WP Admin called ‘stats’ and I set that page to use a custom page template that, instead of calling post content, called my stats code. Custom page templates are pretty powerful, and having the page be a page meant I could allow editors to write and edit the page all they wanted.

But the downside is that making ‘sub’ pages gets messy, and what happens if someone changes the slug or deletes the page or changes the template? No. There had to be a better way that let me force generate the page.

The ‘Better’ Way

This brings me to custom endpoints.

Now there is a huge problem with this, and it’s that if you want to make your custom virtual pages look like the rest of your WordPress site, it’s … messy. This is why BuddyPress generates pages automatically for you. It needs the pages. No matter what, I was going to have to make pages. But there is a distinct difference between 19 pages and 5.

My (unique) situation was that I wanted to have a series of sub pages. That would let me make one page, for example, for ‘roles’ and then automagically generate pages for /role/regular/ and /role/guest/ and so on. And yet, this brings up the other major problem.

You see, it would also mean the slugs /about/regular/ could exist. Obviously there are ways around it but the point to hammer home here is that endpoints are not meant for this. They’re meant for if you want a custom endpoint (hah) on every page of a type.

The (Really) Better Way

Thankfully, there’s a better way!

Rewrite rules and custom query vars will let me do everything I wanted, with only one page for each item.

The Setup

I have five types of pages with ‘extra’ sub pages: newest, role, star, stats, and thumbs. Each one has a few accepted types, but I don’t need to really worry about that just yet, because how I handle it differs on each page (more on that in a second). First I made a list of all my pages, the ‘kind’ of extra they had, and their custom template. Because yes, each page has a custom template.

  • page slug: newest, extra: newtype, template: newtype.php
  • page slug: role, extra: roleype, template: roletype.php
  • page slug: star, extra: starcolor, template: starcolor.php

You’ll notice I went with a theme there. It makes it easier to remember what’s what. Each template is heavily customized for the data within, since each page is wildly different from each other. The one check I make on every page though is that the ‘extra’ value matches what I think it should. For example, here’s the stats header:

$statstype = ( isset($wp_query->query['statistics'] ) )? $wp_query->query['statistics'] : 'main' ;
$validstat = array('death', 'characters', 'shows', 'lists', 'main');

if ( !in_array( $statstype, $validstat ) ){
	wp_redirect( get_site_url().'/stats/' , '301' );
	exit;
}

That means if you go to /stats/humbug/ it redirects you back to the main stats page.

Cool, right? So the question next is how did I get /stats/characters/ to work if there’s no commensurate page?

The WordPress Way

The answer is add_rewrite_rule and query_vars. I made an array with all my page slugs and their extra, which you’ll remember was the same name as the template file. This let me use a series of loops and checks so my code is simpler.

My query arguments are this:

$query_args = array(
	'newest'	=> 'newtype',
	'role'		=> 'roletype',
	'star'		=> 'starcolor',
	'stats'		=> 'statistics',
	'thumbs'	=> 'thumbscore',
		);

Notice how that matches exactly what my design was above? That’s why I take the time to plan all this out.

The next thing I did with all this was to set up my query variables. These loop through the query array and set up a variable for each one.

add_action ('query_vars', 'helf_query_vars');
function helf_query_vars($vars){
	foreach ( $query_args as $argument ) {
		$vars[] = $argument;
	}
	return $vars;
}

What that does is makes a URL like this work: http://example.com/?pagename=stats&statistics=death

It also means this works: http://example.com/stats/?statistics=death

Neither of those are particularly ‘pretty’ permalinks, though, are they? That means it’s time for add_rewrite_rule!

foreach( $query_args as $slug => $query ) {
    add_rewrite_rule(
        '^'.$slug.'/([^/]+)/?$',
        'index.php?pagename='.$slug.'&'.$query.'=$matches[1]',
        'top'
    );
}

This code is actually in an init function, but what it does is make a custom rewrite rule so we can call the URL like this: http://example.com/stats/death/

Which is what I want.

The Whole Code

The following is actually the code I use. Feel free to fork! I put it into a class and did some extra work to call the right templates on the right pages.

class LWTVG_Query_Vars {

	// Constant for the query arguments we allow
	public $query_args = array();

	/**
	 * Construct
	 * Runs the Code
	 *
	 * @since 1.0
	 */
	function __construct() {
		add_action( 'init', array( $this, 'init' ) );

		$this->query_args = array(
			'newest'	=> 'newtype',
			'role'		=> 'roletype',
			'star'		=> 'starcolor',
			'stats'		=> 'statistics',
			'thumbs'	=> 'thumbscore',
		);
	}

	/**
	 * Main Plugin setup
	 *
	 * Adds actions, filters, etc. to WP
	 *
	 * @access public
	 * @return void
	 * @since 1.0
	 */
	function init() {
		// Plugin requires permalink usage - Only setup handling if permalinks enabled
		if ( get_option('permalink_structure') != '' ) {

			// tell WP not to override
			add_action ('query_vars', array($this, 'query_vars'));

			foreach( $this->query_args as $slug => $query ) {
			    add_rewrite_rule(
			        '^'.$slug.'/([^/]+)/?$',
			        'index.php?pagename='.$slug.'&'.$query.'=$matches[1]',
			        'top'
			    );
			}

			// add filter for page
			add_filter( 'page_template', array( $this, 'page_template' ) );

		} else {
			add_action( 'admin_notices', array( $this, 'admin_notice_permalinks' ) );
		}
	}

	/**
	 * No Permalinks Notice
	 *
	 * @since 1.0
	 */
	public function admin_notice_permalinks() {
		echo '<div class="error"><p><strong>Custom Query Vars</strong> require you to use custom permalinks.</p></div>';
	}

	/**
	 * Add the query variables so WordPress won't override it
	 *
	 * @return $vars
	 */
	function query_vars($vars){
		foreach ( $this->query_args as $argument ) {
			$vars[] = $argument;
		}
		return $vars;
	}

	/**
	 * Adds a custom template to the query queue.
	 *
	 * @return $templates
	 */
	function page_template($templates = ""){
		global $wp_query, $post;

		if ( array_key_exists( $post->post_name, $this->lez_query_args ) )
			$the_template = $this->lez_query_args[$post->post_name].'.php';

		foreach ( $this->lez_query_args as $argument ) {
			if( isset( $wp_query->query[$argument] ) ) {
				$templates = dirname( dirname( __FILE__ ) ) . '/page-templates/' . $the_template;
			}
		}

		return $templates;
	}

}

new LWTVG_Query_Vars();

PS: Yes, I totally built it all out in endpoints before I got smarter.

Reader Interactions

%d bloggers like this: