There are many use cases for a virtual page. Let’s say showing the invoice or payment status page for a payment plugin in the front-end, without compromising active theme. I needed one recently to show payment statuses. Here is how I implemented it.

1. Hook “init” to catch the specific slug

First we need to check current page slug to see if that is our target page inside the init hook.

class My_Virtual_Page {
    public function __construct() {
        add_action( 'init', array( $this, 'init' ) );
    }

    /**
     * Hook to add the virtual page
     */
    public function init() {
       if ( get_option( 'permalink_structure' ) ) {
            $param = trim( parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ), '/' );
        } else {
            parse_str( parse_url( $_SERVER['REQUEST_URI'], PHP_URL_QUERY ), $params );
            $param = ( isset( $params['page_id'] ) ? $params['page_id'] : false );
        }

        if( $param == 'my-payment-page' ) {
            //Enqueue any page specific styles and scripts here
            add_filter( 'the_posts', array( $this, 'my_virtual_payment_page' ) );
        }
    }
}

Let’s say our virtual page is my-payment-page. So, the expected URL is either mydomain.com/my-payment-page or mydomain.com/?page_id=my-payment-page.

If we find our target parameter inside the init hook, we en-queue any required styles and scripts and add a filter for the content.

Note: The “slug” have to be unique to avoid conflict with any existing page.

2. Prepare the page object

Inside the filter, we prepare the content of the virtual page. Its pretty straight forward. We create a basic post object and fill the object with the custom values we want to display.

class My_Virtual_Page {
    /*........*/
    /**
     * @param $posts
     * The virtual page filter
     *
     * @return array
     */
    function my_virtual_payment_page( $posts ) {
        global $wp, $wp_query;

        //Just double checking. Can be ignored.
        if ( strcasecmp( $wp->request, 'my-payment-page' ) !== 0 ) {
            return $posts;
        }

        $content = '<p>HTML Page content here!</p>';

        $post = $this->my_virtual_post_object( 'my-payment-page', __( 'Virtual payment page', 'domain' ), $content );
    }

    /**
     * @param $slug
     * @param $title
     * @param $content
     *
     * Generate the post object dynamically
     *
     * @return stdClass
     */
    function my_virtual_post_object( $slug, $title, $content ) {
        $post                        = new stdClass;
        $post->ID                    = -1;
        $post->post_author           = 1;
        $post->post_date             = current_time( 'mysql' );
        $post->post_date_gmt         = current_time( 'mysql', 1 );
        $post->post_content          = $content;
        $post->post_title            = $title;
        $post->post_excerpt          = '';
        $post->post_status           = 'publish';
        $post->comment_status        = 'closed';
        $post->ping_status           = 'closed';
        $post->post_password         = '';
        $post->post_name             = $slug;
        $post->to_ping               = '';
        $post->pinged                = '';
        $post->modified              = $post->post_date;
        $post->modified_gmt          = $post->post_date_gmt;
        $post->post_content_filtered = '';
        $post->post_parent           = 0;
        $post->guid                  = get_home_url( 1, '/' . $slug );
        $post->menu_order            = 0;
        $post->post_type             = 'page';
        $post->post_mime_type        = '';
        $post->comment_count         = 0;

        return $post;
    }
}

3. Display the page

Finally we display the post by returning the post object. Before doing that, we need to set the flags of $wp_query to simulate that a post is found.

class My_Virtual_Page {
    /*........*/
    function my_virtual_payment_page( $posts ) {
        /*.......*/
        $post = $this->my_virtual_post_object( 'my-payment-page', __( 'Virtual payment page', 'domain' ), $content );

        // set filter results
        $posts = array( $post );

        // reset wp_query properties to simulate a found 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;
        unset( $wp_query->query['error'] );
        $wp_query->query_vars['error'] = '';
        $wp_query->is_404              = false;

        return ( $posts );
    }
    /*........*/
}

Putting it all together

class My_Virtual_Page {
    public function __construct() {
        add_action( 'init', array( $this, 'init' ) );
    }

    /**
     * Hook to add the virtual page
     */
    public function init() {
       if ( get_option( 'permalink_structure' ) ) {
            $param = trim( parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ), '/' );
        } else {
            parse_str( parse_url( $_SERVER['REQUEST_URI'], PHP_URL_QUERY ), $params );
            $param = ( isset( $params['page_id'] ) ? $params['page_id'] : false );
        }

        if( $param == 'my-payment-page' ) {
            //Enqueue any page specific styles and scripts here
            add_filter( 'the_posts', array( $this, 'my_virtual_payment_page' ) );
        }
    }

    /**
     * @param $posts
     * The virtual page filter
     *
     * @return array
     */
    function my_virtual_payment_page( $posts ) {
        global $wp, $wp_query;

        //Just double checking. Can be ignored.
        if ( strcasecmp( $wp->request, 'my-payment-page' ) !== 0 ) {
            return $posts;
        }

        $content = '<p>HTML Page content here!</p>';

        $post = $this->my_virtual_post_object( 'my-payment-page', __( 'Virtual payment page', 'domain' ), $content );

        // set filter results
        $posts = array( $post );

        // reset wp_query properties to simulate a found 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;
        unset( $wp_query->query['error'] );
        $wp_query->query_vars['error'] = '';
        $wp_query->is_404              = false;

        return ( $posts );
    }

    /**
     * @param $slug
     * @param $title
     * @param $content
     *
     * Generate the post object dynamically
     *
     * @return stdClass
     */
    function my_virtual_post_object( $slug, $title, $content ) {
        $post                        = new stdClass;
        $post->ID                    = -1;
        $post->post_author           = 1;
        $post->post_date             = current_time( 'mysql' );
        $post->post_date_gmt         = current_time( 'mysql', 1 );
        $post->post_content          = $content;
        $post->post_title            = $title;
        $post->post_excerpt          = '';
        $post->post_status           = 'publish';
        $post->comment_status        = 'closed';
        $post->ping_status           = 'closed';
        $post->post_password         = '';
        $post->post_name             = $slug;
        $post->to_ping               = '';
        $post->pinged                = '';
        $post->modified              = $post->post_date;
        $post->modified_gmt          = $post->post_date_gmt;
        $post->post_content_filtered = '';
        $post->post_parent           = 0;
        $post->guid                  = get_home_url( 1, '/' . $slug );
        $post->menu_order            = 0;
        $post->post_type             = 'page';
        $post->post_mime_type        = '';
        $post->comment_count         = 0;

        return $post;
    }
}

That’s it. Enjoy!

Published by Ashiqur Rahman

Programmer and photography enthusiast.

Join the Conversation

8 Comments

  1. This was very helpful. Thank you.

    Just a couple of small contributions:

    For “$post->post_tyle”, you probably want $post->post_type.

    And for “$post = $this->my_virtual_post_object( ‘my-payment-page’, __( ‘Virtual payment page’, ‘domain’ ), $content );” it seems like what you want is “$post = $this->swp_post_object( ‘my-payment-page’, __( ‘Virtual payment page’, ‘domain’ ), $content );

    Depending on the nature of your content, you may want to disable wpautop. This function automatically inserts paragraphs in the content where you may not want them. This is accomplished with the code: “remove_filter ( ‘the_content’, ‘wpautop’ )”.

    Again, depending on the nature of your content, you may get recursion. If this happens you can remove the filter that was set in the init() function. This should be done at the beginning of “my_virtual_payment_page()”, at which point it will have already served its purpose.

    remove_filter( ‘the_posts’, array( $this, ‘my_virtual_payment_page’ ) );

  2. Thank you for this great tutorial! But I don’t understand the function swp_post_object(). As far as I understand it creates and returns a post object, right? But when is it called or executed, I can’t see that?

  3. On the latest Gutenberg theme (2022) my virtual page content also showed up in header and footer.
    This is because block templates are also posts on the latest theme shipped.

    So I needed to adjust the “the_posts” filter to include the query parameter:
    add_filter( ‘the_posts’, array( $this, ‘my_virtual_payment_page’ ), 10, 2 );

    Then I added a check into the my_virtual_payment_page method:

    function my_virtual_payment_page( $posts, $query ) {
    global $wp;
    if($query->is_main_query()) {

    $query->is_page = true;
    $query->is_singular = true;

    return ( $posts );
    }
    }

    Now it works. 🙂

  4. I have tried your stuff, but I am unable to understand where is the function named “my_virtual_post_object()” defined. Will you please guide?

Leave a comment

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