21

I'm developing some plugin where I would like to enable custom pages. In my case some custom page would contain a form like contact form (not literally). When user will fill out this form and send it, there should be the next step which will require more information. Lets say that the first page with form would be located at www.domain.tld/custom-page/ and after successful form submission, the user should be redirected to www.domain.tld/custom-page/second. Template with HTML elements and PHP code should be also custom.

I think that a part of the problem is possible to achieve with custom URL rewrites, but the other parts are currently unknown to me. I really don't know where should I start looking and what's the correct naming for that problem. Any help would be really appreciated.

4
  • Do you want these pages to be stored in WordPress or 'virtual'?
    – Welcher
    Commented Sep 22, 2014 at 16:47
  • You'd have to use the rewrite api. This shouldn't be too difficult. Make sure to post the data to the second page and you should be fine. Commented Sep 22, 2014 at 17:15
  • @Welcher: These pages are not the same as WordPress offers in dashboard. They should just save data into database, but that's not the problem. @.setterGetter: Do you have any example how to pass data from the first page to second and where (action?) to include PHP file which shows the form? Commented Sep 22, 2014 at 17:35
  • Did you consider using a single page form, with multiple slides (javascript and/or css) of input fields?
    – birgire
    Commented Sep 24, 2014 at 17:27

3 Answers 3

74
+100

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>
10
  • 10
    Amazing comprehensive answer, not only on the problem itself, but also on creating a OOP style plug-in and more. You got my upvote for sure, imagine more, one for every level the answer covers. Commented Sep 25, 2014 at 10:50
  • 2
    Very slick and straight forward solution. Updvoted, tweeted.
    – kaiser
    Commented Sep 25, 2014 at 13:23
  • The code in Controller is a little bit wrong... checkRequest() is getting path info from home_url() which returns localhost/wordpress. After preg_replace and add_query_arg, this url becomes /wordpress/virtual-page. And after the trim in checkRequest this url is wordpress/virtual. This would work if the wordpress would be installed in the root folder of domain. Can you please provide fix for that problem because I can't find appropriate function which would return right url. Thank you for everything! (I will accept answer after it will become perfect :) Commented Sep 25, 2014 at 18:20
  • 2
    Congratulations, nice answer and I need to see this lot of work as free solution.
    – bueltge
    Commented Sep 25, 2014 at 19:11
  • @G.M.: In my case WordPress is installed in .../htdocs/wordpress/ and the site is available on localhost/wordpress. home_url() returns localhost/wordpress and add_query_arg(array()) returns /wordpress/virtual-page/. When we're comparing $path and trimmed $this->pages->current()->getUrl() in checkRequest() is problem because the $path is wordpress/virtual-page and page's trimmed url is virtual-page. Commented Sep 26, 2014 at 16:13
1

A lot simpler solution is automagically adding the page if it does not exist. We can use a custom meta key to check its existence. We can do callback in the the_content filter if the current id is the same as the page id.

        $this->id = $this->settings['id'];
        add_action('init', function (){
            $page = get_posts([
                'meta_key' => 'my_frontend_id',
                'meta_value' => $this->id,
                'post_type' => 'page',
                'post_status' => 'any',
                'numberposts' => 1
            ])[0];
            if (is_null($page)){
                $pageId = wp_insert_post($this->settings);
                add_post_meta($pageId, 'my_frontend_id', $this->id, true);
            }
            else
                $pageId = $page->ID;
            
            add_filter('the_content', function ($content) use ($pageId){
                if (is_singular() && in_the_loop() && is_main_query() && get_the_ID() === $pageId){
                    ob_start();
                    $this->render();
                    return ob_get_clean();
                }
                return $content;
            }, 1);
        });

Though there is a catch here. For example password protection does not work with this approach. It takes an effort to protect these pages properly against unauthorized read or write. I rather ended up using short codes instead of using this filter, though I can imagine a scenario where it is useful.

0

I once used a solution described here: http://scott.sherrillmix.com/blog/blogger/creating-a-better-fake-post-with-a-wordpress-plugin/

Actually, when I was using it, I extend the solution in a way I can register more than one page a time (the rest of a code is +/- similar to the solution I'm linking from a paragraph above).

The solution requires you to have nice permalinks allowed tho...

<?php

class FakePages {

    public function __construct() {
        add_filter( 'the_posts', array( $this, 'fake_pages' ) );
    }

    /**
     * Internally registers pages we want to fake. Array key is the slug under which it is being available from the frontend
     * @return mixed
     */
    private static function get_fake_pages() {
        //http://example.com/fakepage1
        $fake_pages['fakepage1'] = array(
            'title'   => 'Fake Page 1',
            'content' => 'This is a content of fake page 1'
        );
        //http://example.com/fakepage2
        $fake_pages['fakepage2'] = array(
            'title'   => 'Fake Page 2',
            'content' => 'This is a content of fake page 2'
        );

        return $fake_pages;
    }

    /**
     * Fakes get posts result
     *
     * @param $posts
     *
     * @return array|null
     */
    public function fake_pages( $posts ) {
        global $wp, $wp_query;
        $fake_pages       = self::get_fake_pages();
        $fake_pages_slugs = array();
        foreach ( $fake_pages as $slug => $fp ) {
            $fake_pages_slugs[] = $slug;
        }
        if ( true === in_array( strtolower( $wp->request ), $fake_pages_slugs )
             || ( true === isset( $wp->query_vars['page_id'] )
                  && true === in_array( strtolower( $wp->query_vars['page_id'] ), $fake_pages_slugs )
            )
        ) {
            if ( true === in_array( strtolower( $wp->request ), $fake_pages_slugs ) ) {
                $fake_page = strtolower( $wp->request );
            } else {
                $fake_page = strtolower( $wp->query_vars['page_id'] );
            }
            $posts                  = null;
            $posts[]                = self::create_fake_page( $fake_page, $fake_pages[ $fake_page ] );
            $wp_query->is_page      = true;
            $wp_query->is_singular  = true;
            $wp_query->is_home      = false;
            $wp_query->is_archive   = false;
            $wp_query->is_category  = false;
            $wp_query->is_fake_page = true;
            $wp_query->fake_page    = $wp->request;
            //Longer permalink structures may not match the fake post slug and cause a 404 error so we catch the error here
            unset( $wp_query->query["error"] );
            $wp_query->query_vars["error"] = "";
            $wp_query->is_404              = false;
        }

        return $posts;
    }

    /**
     * Creates virtual fake page
     *
     * @param $pagename
     * @param $page
     *
     * @return stdClass
     */
    private static function create_fake_page( $pagename, $page ) {
        $post                 = new stdClass;
        $post->post_author    = 1;
        $post->post_name      = $pagename;
        $post->guid           = get_bloginfo( 'wpurl' ) . '/' . $pagename;
        $post->post_title     = $page['title'];
        $post->post_content   = $page['content'];
        $post->ID             = - 1;
        $post->post_status    = 'static';
        $post->comment_status = 'closed';
        $post->ping_status    = 'closed';
        $post->comment_count  = 0;
        $post->post_date      = current_time( 'mysql' );
        $post->post_date_gmt  = current_time( 'mysql', 1 );

        return $post;
    }
}

new FakePages();
2
  • What about custom template where I can place my form? Commented Sep 24, 2014 at 17:51
  • content in the array when you are registering the fake page is being displayed in the body of the page - it can contain a HTML as well as simple text or even a shortcode. Commented Sep 24, 2014 at 17:53

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

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