Wordpress - Custom pages with plugin

When you visit a frontend page, WordPress will query the database and if your page does not exist in the database, that query is not needed and is just a waste of resources.

Luckily, WordPress offers a way to handle frontend requests in a custom way. That is done thanks to the 'do_parse_request' filter.

Returning false on that hook, you will be able to stop WordPress from processing requests and do it in your own custom way.

That said, I want to share a way to build a simple OOP plugin that can handle virtual pages in a easy to use (and re-use) way.

What we need

  • A class for virtual page objects
  • A controller class, that will look at a request and if it is for a virtual page, show it using the proper template
  • A class for template loading
  • Main plugin files to add the hooks that will make everything work

Interfaces

Before building classes, let's write the interfaces for the 3 objects listed above.

First the page interface (file PageInterface.php):

<?php
namespace GM\VirtualPages;

interface PageInterface {

    function getUrl();

    function getTemplate();

    function getTitle();

    function setTitle( $title );

    function setContent( $content );

    function setTemplate( $template );

    /**
     * Get a WP_Post build using virtual Page object
     *
     * @return \WP_Post
     */
    function asWpPost();
}

Most methods are just getters and setters, no need for explanation. Last method should be used to get a WP_Post object from a virtual page.

The controller interface (file ControllerInterface.php):

<?php
namespace GM\VirtualPages;

interface ControllerInterface {

    /**
     * Init the controller, fires the hook that allows consumer to add pages
     */
    function init();

    /**
     * Register a page object in the controller
     *
     * @param  \GM\VirtualPages\Page $page
     * @return \GM\VirtualPages\Page
     */
    function addPage( PageInterface $page );

    /**
     * Run on 'do_parse_request' and if the request is for one of the registered pages
     * setup global variables, fire core hooks, requires page template and exit.
     *
     * @param boolean $bool The boolean flag value passed by 'do_parse_request'
     * @param \WP $wp       The global wp object passed by 'do_parse_request'
     */  
    function dispatch( $bool, \WP $wp ); 
}

and the template loader interface (file TemplateLoaderInterface.php ):

<?php
namespace GM\VirtualPages;

interface TemplateLoaderInterface {

    /**
     * Setup loader for a page objects
     *
     * @param \GM\VirtualPagesPageInterface $page matched virtual page
     */
    public function init( PageInterface $page );

    /**
     * Trigger core and custom hooks to filter templates,
     * then load the found template.
     */
    public function load();
}

phpDoc comments should be pretty clear for these interfaces.

The Plan

Now that we have interfaces, and before writing concrete classes, let's review our workflow:

  • First we instantiate a Controller class (implementing ControllerInterface) and inject (probably in a constructor) an instance of TemplateLoader class (implementing TemplateLoaderInterface)
  • On init hook we call the ControllerInterface::init() method to setup the controller and to fire the hook that consumer code will use to add virtual pages.
  • On 'do_parse_request' we will call ControllerInterface::dispatch(), and there we will check all the virtual pages added and if one of them has same URL of current request, display it; after having set all the core global variables ($wp_query, $post). We will also use TemplateLoader class to load the right template.

During this workflow we will trigger some core hooks, like wp, template_redirect, template_include... to make the plugin more flexible and ensure compatibility with core and other plugins, or at least with a good number of them.

Aside from previous workflow, we also will need to:

  • Clean up hooks and global variables after main loop runs, again to improve compatibility with core and 3rd party code
  • Add a filter on the_permalink to make it return the right virtual page URL when needed.

Concrete Classes

Now we can code our concrete classes. Let's start with page class (file Page.php):

<?php
namespace GM\VirtualPages;

class Page implements PageInterface {

    private $url;
    private $title;
    private $content;
    private $template;
    private $wp_post;

    function __construct( $url, $title = 'Untitled', $template = 'page.php' ) {
        $this->url = filter_var( $url, FILTER_SANITIZE_URL );
        $this->setTitle( $title );
        $this->setTemplate( $template);
    }

    function getUrl() {
        return $this->url;
    }

    function getTemplate() {
        return $this->template;
    }

    function getTitle() {
        return $this->title;
    }

    function setTitle( $title ) {
        $this->title = filter_var( $title, FILTER_SANITIZE_STRING );
        return $this;
    }

    function setContent( $content ) {
        $this->content = $content;
        return $this;
    }

    function setTemplate( $template ) {
        $this->template = $template;
        return $this;
    }

    function asWpPost() {
        if ( is_null( $this->wp_post ) ) {
            $post = array(
                'ID'             => 0,
                'post_title'     => $this->title,
                'post_name'      => sanitize_title( $this->title ),
                'post_content'   => $this->content ? : '',
                'post_excerpt'   => '',
                'post_parent'    => 0,
                'menu_order'     => 0,
                'post_type'      => 'page',
                'post_status'    => 'publish',
                'comment_status' => 'closed',
                'ping_status'    => 'closed',
                'comment_count'  => 0,
                'post_password'  => '',
                'to_ping'        => '',
                'pinged'         => '',
                'guid'           => home_url( $this->getUrl() ),
                'post_date'      => current_time( 'mysql' ),
                'post_date_gmt'  => current_time( 'mysql', 1 ),
                'post_author'    => is_user_logged_in() ? get_current_user_id() : 0,
                'is_virtual'     => TRUE,
                'filter'         => 'raw'
            );
            $this->wp_post = new \WP_Post( (object) $post );
        }
        return $this->wp_post;
    }
}

Nothing more than implementing the interface.

Now the controller class (file Controller.php):

<?php
namespace GM\VirtualPages;

class Controller implements ControllerInterface {

    private $pages;
    private $loader;
    private $matched;

    function __construct( TemplateLoaderInterface $loader ) {
        $this->pages = new \SplObjectStorage;
        $this->loader = $loader;
    }

    function init() {
        do_action( 'gm_virtual_pages', $this ); 
    }

    function addPage( PageInterface $page ) {
        $this->pages->attach( $page );
        return $page;
    }

    function dispatch( $bool, \WP $wp ) {
        if ( $this->checkRequest() && $this->matched instanceof Page ) {
            $this->loader->init( $this->matched );
            $wp->virtual_page = $this->matched;
            do_action( 'parse_request', $wp );
            $this->setupQuery();
            do_action( 'wp', $wp );
            $this->loader->load();
            $this->handleExit();
        }
        return $bool;
    }

    private function checkRequest() {
        $this->pages->rewind();
        $path = trim( $this->getPathInfo(), '/' );
        while( $this->pages->valid() ) {
            if ( trim( $this->pages->current()->getUrl(), '/' ) === $path ) {
                $this->matched = $this->pages->current();
                return TRUE;
            }
            $this->pages->next();
        }
    }        

    private function getPathInfo() {
        $home_path = parse_url( home_url(), PHP_URL_PATH );
        return preg_replace( "#^/?{$home_path}/#", '/', esc_url( add_query_arg(array()) ) );
    }

    private function setupQuery() {
        global $wp_query;
        $wp_query->init();
        $wp_query->is_page       = TRUE;
        $wp_query->is_singular   = TRUE;
        $wp_query->is_home       = FALSE;
        $wp_query->found_posts   = 1;
        $wp_query->post_count    = 1;
        $wp_query->max_num_pages = 1;
        $posts = (array) apply_filters(
            'the_posts', array( $this->matched->asWpPost() ), $wp_query
        );
        $post = $posts[0];
        $wp_query->posts          = $posts;
        $wp_query->post           = $post;
        $wp_query->queried_object = $post;
        $GLOBALS['post']          = $post;
        $wp_query->virtual_page   = $post instanceof \WP_Post && isset( $post->is_virtual )
            ? $this->matched
            : NULL;
    }

    public function handleExit() {
        exit();
    }
}

Essentially the class creates an SplObjectStorage object where all the added pages objects are stored.

On 'do_parse_request', the controller class loops this storage to find a match for the current URL in one of the added pages.

If it is found, the class does exactly what we planned: trigger some hooks, setup variables and load the template via the class extending TemplateLoaderInterface. After that, just exit().

So let's write the last class:

<?php
namespace GM\VirtualPages;

class TemplateLoader implements TemplateLoaderInterface {

    public function init( PageInterface $page ) {
        $this->templates = wp_parse_args(
            array( 'page.php', 'index.php' ), (array) $page->getTemplate()
        );
    }

    public function load() {
        do_action( 'template_redirect' );
        $template = locate_template( array_filter( $this->templates ) );
        $filtered = apply_filters( 'template_include',
            apply_filters( 'virtual_page_template', $template )
        );
        if ( empty( $filtered ) || file_exists( $filtered ) ) {
            $template = $filtered;
        }
        if ( ! empty( $template ) && file_exists( $template ) ) {
            require_once $template;
        }
    }
}

Templates stored in the virtual page are merged in an array with defaults page.php and index.php, before loading template 'template_redirect' is fired, to add flexibility and improve compatibility.

After that, the found template passes through the custom 'virtual_page_template' and the core 'template_include' filters: again for flexibility and compatibility.

Finally the template file is just loaded.

Main plugin file

At this point we need to write the file with plugin headers and use it to add the hooks that will make our workflow happen:

<?php namespace GM\VirtualPages;

/*
  Plugin Name: GM Virtual Pages
 */

require_once 'PageInterface.php';
require_once 'ControllerInterface.php';
require_once 'TemplateLoaderInterface.php';
require_once 'Page.php';
require_once 'Controller.php';
require_once 'TemplateLoader.php';

$controller = new Controller ( new TemplateLoader );

add_action( 'init', array( $controller, 'init' ) );

add_filter( 'do_parse_request', array( $controller, 'dispatch' ), PHP_INT_MAX, 2 );

add_action( 'loop_end', function( \WP_Query $query ) {
    if ( isset( $query->virtual_page ) && ! empty( $query->virtual_page ) ) {
        $query->virtual_page = NULL;
    }
} );

add_filter( 'the_permalink', function( $plink ) {
    global $post, $wp_query;
    if (
        $wp_query->is_page && isset( $wp_query->virtual_page )
        && $wp_query->virtual_page instanceof Page
        && isset( $post->is_virtual ) && $post->is_virtual
    ) {
        $plink = home_url( $wp_query->virtual_page->getUrl() );
    }
    return $plink;
} );

In the real file we will probably add more headers, like plugin and author links, description, license, etc.

Plugin Gist

Ok, we are done with our plugin. All the code can be find in a Gist here.

Adding Pages

Plugin is ready and working, but we haven't added any pages.

That can be done inside the plugin itself, inside theme functions.php, in another plugin, etc.

Add pages is just a matter of:

<?php
add_action( 'gm_virtual_pages', function( $controller ) {

    // first page
    $controller->addPage( new \GM\VirtualPages\Page( '/custom/page' ) )
        ->setTitle( 'My First Custom Page' )
        ->setTemplate( 'custom-page-form.php' );

    // second page
    $controller->addPage( new \GM\VirtualPages\Page( '/custom/page/deep' ) )
        ->setTitle( 'My Second Custom Page' )
        ->setTemplate( 'custom-page-deep.php' );

} );

And so on. You can add all the pages you need, just remember to use relative URLs for the pages.

Inside the template file you can use all WordPress template tags, and you can write all the PHP and HTML you need.

The global post object is filled with data coming from our virtual page. The virtual page itself can be accessed via $wp_query->virtual_page variable.

To get URL for a virtual page is as easy as passing to home_url() the same path used to create the page:

$custom_page_url = home_url( '/custom/page' );

Note that in main loop in the loaded template, the_permalink() will return correct permalink to virtual page.

Notes on styles / scripts for virtual pages

Probably when virtual pages are added, it's also desirable to have custom styles/scripts enqueued and then just use wp_head() in custom templates.

That's very easy, because virtual pages are easily recognized looking at $wp_query->virtual_page variable and virtual pages can be distinguished one from another looking at their URLs.

Just an example:

add_action( 'wp_enqueue_scripts', function() {

    global $wp_query;

    if (
        is_page()
        && isset( $wp_query->virtual_page )
        && $wp_query->virtual_page instanceof \GM\VirtualPages\PageInterface
    ) {

        $url = $wp_query->virtual_page->getUrl();

        switch ( $url ) {
            case '/custom/page' : 
                wp_enqueue_script( 'a_script', $a_script_url );
                wp_enqueue_style( 'a_style', $a_style_url );
                break;
            case '/custom/page/deep' : 
                wp_enqueue_script( 'another_script', $another_script_url );
                wp_enqueue_style( 'another_style', $another_style_url );
                break;
        }
    }

} );

Notes to OP

Passing data from a page to another is not related to these virtual pages, but is just a generic task.

However, if you have a form in the first page, and want to pass data from there to the second page, simply use the URL of the second page in form action property.

E.g. in the first page template file you can:

<form action="<?php echo home_url( '/custom/page/deep' ); ?>" method="POST">
    <input type="text" name="testme">
</form>

and then in the second page template file:

<?php $testme = filter_input( INPUT_POST, 'testme', FILTER_SANITIZE_STRING ); ?>
<h1>Test-Me value form other page is: <?php echo $testme; ?></h1>