WordPress URLs without posts

Yes, it is possible.

WordPress frontend workflow can be summarized like so:

  1. A url is visited
  2. By checking current url against all the defaults and custom rewrite rules the url is “converted” to a set of arguments for WP_Query. This is done by the parse_request method of an instance of the WP class stored in the global $wp variable
  3. An instance of WP_Query (saved in the $wp_query global variable) is used to query database and getting the posts related to the arguments retrieved at point #2. This is referenced as the “main query”
  4. Based on the query arguments, a template is choosen according to template hierarchy and it is loaded and used to display results

So, even if you register a set of custom rewrite rules, they will be used to query posts.

However, if you look at the source of parse_request method, in the very first lines you see this:

if ( ! apply_filters( 'do_parse_request', true, $this, $extra_query_vars ) )
  return;

So by attacching a callback to the ‘do_parse_request’ filter, you’ll be able to stop the WordPress parsing process and do whatever you need.

There are different way to do this task, here for sake of simplicity, I’ll give you a rough example.

As said, we need to make a custom url match to… something, probably a callback that retrieve some data and a view to display it.

To make code reusable, we can use a class that accept custom url settings via a filter, and use that custom urls settings to show whatever we need, using a callback to obtain some data and a view to display them.

class MyCustomUrlParser {

  private $matched = array();

  /**
   * Run a filter to obtain some custom url settings, compare them to the current url
   * and if a match is found the custom callback is fired, the custom view is loaded
   * and request is stopped.
   * Must run on 'do_parse_request' filter hook.
   */
  public function parse( $result ) {
    if ( current_filter() !== 'do_parse_request' ) {
      return $result;
    }
    $custom_urls = (array) apply_filters( 'my_custom_urls', array() );
    if ( $this->match( $custom_urls ) && $this->run() ) {
      exit(); // stop WordPress workflow
    }
    return $result;
  }

  private function match( Array $urls = array() ) {
    if ( empty( $urls ) ) {
      return FALSE;
    }
    $current = $this->getCurrentUrl();
    $this->matched = array_key_exists( $current, $urls ) ? $urls[$current] : FALSE;
    return ! empty( $this->matched );
  }

  private function run() {
    if (
      is_array( $this->matched )
      && isset( $this->matched['callback'] )
      && is_callable( $this->matched['callback'] )
      && isset( $this->matched['view'] )
      && is_readable( $this->matched['view'] )
    ) {
      $GLOBALS['wp']->send_headers();
      $data = call_user_func( $this->matched['callback'] );
      require_once $this->matched['view'];
      return TRUE;
    }
  }

  private function getCurrentUrl() {
    $home_path = rtrim( parse_url( home_url(), PHP_URL_PATH ), "https://wordpress.stackexchange.com/" );
    $path = rtrim( substr( add_query_arg( array() ), strlen( $home_path ) ), "https://wordpress.stackexchange.com/" );
    return ( $path === '' ) ? "https://wordpress.stackexchange.com/" : $path;
  }

}

This is a rough class that let users set custom url settings via a filter (‘my_custom_urls’). Custom url settings must be an array where keys are relative urls and every value is an array containing two keyed values: one with key ‘callback’ and one with key ‘view’.

The callback is a callable (anything for which is_callable returns true) and the view is a file used to render the data returned by the callable and accessible in the view file in the $data variable.

Here an example how to use the class.

// first of all let's set custom url settings
add_filter( 'my_custom_urls', 'set_my_urls' );

function set_my_urls( $urls = array() ) {
  $my_urls = array(
     '/customcontent/alink' => array(
       'callback' => 'my_first_content_callback',
       'view'     => get_template_directory() . '/views/my_first_view.php'
     ),
     '/customcontent/anotherlink' => array(
       'callback' => 'my_second_content_callback',
       'view'     => get_template_directory() . '/views/my_second_view.php'
     )
  );
  return array_merge( (array) $urls, $my_urls ); 
}

// require the file that contain the MyCustomUrlParser class
require '/path/to/MyCustomUrlParser';

// attach MyCustomUrlParser::parse() method to 'do_parse_request' filter hook
add_filter( 'do_parse_request', array( new MyCustomUrlParser, 'parse' ) );

Of course, we need to write my_first_content_callback and my_second_content_callback and also my_first_view.php and my_second_view.php.

As example, the callback would be something like this:

function my_first_content_callback() {
  $content = get_transient( 'my_first_content' );
  if ( empty( $content ) ) {
     $api = a_method_to_get_an_external_api_object();
     $json = $api->fetch_some_json_data();
     $content = json_decode( $json );
     // cache content for 1 hour
     set_transient( 'my_first_content', $content, HOUR_IN_SECONDS );
  }
  return $content;
}

Note that whatever callback returns is stored in the $data variable accessible in the view. In facts, a view file would appear something like this:

<?php get_header(); ?>

<h1>My Content from API</h1>

<div>
   <pre><?php print_r( $data ); ?></pre>
</div>

<?php get_footer() ?>

This works and is pretty reusable, you can set all the custom urls you want by using the 'my_custom_urls' filter.

However, there is downside: all the matching against the current url is done via an exact match, but using a regex matching system it would be a lot better for large projects because you can use the urls to pass some variables to callbacks.

E.g. if you have an url settings like this:

'/customcontent/alink/page/{page}' => array(
   'callback' => 'my_first_content_callback',
   'view'     => get_template_directory() . '/views/my_first_view.php'
) 

using a regex system is possible to make the {page} part variable and the matched value can be passed to the callback to retrieve different data.

This is what is commonly called a routing system, and there are some useful PHP libraries like FastRoute, Pux and Symfony Routing Component that can help you to use the workflow I explained here and build your own regex-based routing system in WordPress.

If you have PHP 5.4+ (that is really recommended) there is a plugin I wrote called Cortex that implements Symfony Routing Component and make it usable inside WordPress both for standard WordPress queries (showing posts) or even for custom content like you need.

Leave a Comment