Rectangle 27 0

php WordPress pre_get_posts in place of query_posts on pages?


// Paging
if ( empty($q['nopaging']) && !$this->is_singular ) {
    $page = absint($q['paged']);
    if ( !$page )
        $page = 1;

    // If 'offset' is provided, it takes precedence over 'paged'.
    if ( isset( $q['offset'] ) && is_numeric( $q['offset'] ) ) {
        $q['offset'] = absint( $q['offset'] );
        $pgstrt = $q['offset'] . ', ';
    } else {
        $pgstrt = absint( ( $page - 1 ) * $q['posts_per_page'] ) . ', ';
    }
    $limits = 'LIMIT ' . $pgstrt . $q['posts_per_page'];
}
example.com/show
example.com/show/page/2
example.com/show/page/3
function b2e_pre_get_posts( $query )
{
    $target_page = 'show';                             // EDIT to your needs

    if (    ! is_admin()                               // front-end only
         && $query->is_main_query()                    // main query only
         && $target_page === $query->get( 'pagename' ) // matching pagename only
    ) {
        // modify query_vars:
        $query->set( 'post_type',      'post'                 );  // override 'post_type'
        $query->set( 'pagename',       null                   );  // override 'pagename'
        $query->set( 'posts_per_page', 10                     );
        $query->set( 'meta_key',       'wpb_post_views_count' );
        $query->set( 'orderby',        'meta_value_num'       );
        $query->set( 'order',          'DESC'                 );

        // Support for paging
        $query->is_singular = 0;

        // custom page template
        add_filter( 'template_include', 'b2e_template_include', 99 );
    }
}

add_action( 'pre_get_posts', 'b2e_pre_get_posts' );
function b2e_template_include( $template )
{
    $target_tpl = 'tpl_show.php'; // EDIT to your needs

    remove_filter( 'template_include', 'b2e_template_include', 99 );

    $new_template = locate_template( array( $target_tpl ) );

    if ( ! empty( $new_template ) )
        $template = $new_template; ;

    return $template;
}
tpl_show.php

Also removed the is_page() check within the pre_get_posts hook, since it might still give some irregularities in some cases. The reason is that the query-object is not always available. This is being worked on, see e.g. #27015. There are workarounds possible if we want to use the is_page() or is_front_page().

Ceate a page called for example Show with the slug:

Create a custom page template:

I constructed the following table, just to get a better overview of some of the properties and query varaiables of the main WP_Query object, for a given slug:

I updated the answer and removed the query-object part modification, based on the suggestion from @PieterGoosen, since it could e.g. break the breadcrumbs on his setup.

I've been playing with the pre_get_posts hook and here's one idea

It's interesting to note that the pagination in WP_Query depends on the nopaging not being set and the current page not being singular (from the 4.4 source):

No problem. Enjoy the rest of your Sunday. Last day to "relax" before everything goes back to normal tommorrow

Thanks @andy, it was an interesting puzzle ;-)

Thanks for the update @PieterGoosen and generous bounty. I just updated the answer. I guess I was trying to remove the page identity when I originally posted it, but I've now skipped that part, thanks to your suggestion.

We construct the following pre_get_posts action callback:

We could have used other filter/hooks, but here we used pre_get_posts as mentioned by the OP.

Will award the bounty to you as soon as I can, unfortunately I cannot award the bounty within 24 hours of starting one. Enjoy ;

located in the current theme directory.

where we can see that the LIMIT part of the generated SQL query is within the conditional check. This explains why we modify the is_singular property above.

Note
Rectangle 27 0

php WordPress pre_get_posts in place of query_posts on pages?


// Paging
if ( empty($q['nopaging']) && !$this->is_singular ) {
    $page = absint($q['paged']);
    if ( !$page )
        $page = 1;

    // If 'offset' is provided, it takes precedence over 'paged'.
    if ( isset( $q['offset'] ) && is_numeric( $q['offset'] ) ) {
        $q['offset'] = absint( $q['offset'] );
        $pgstrt = $q['offset'] . ', ';
    } else {
        $pgstrt = absint( ( $page - 1 ) * $q['posts_per_page'] ) . ', ';
    }
    $limits = 'LIMIT ' . $pgstrt . $q['posts_per_page'];
}
example.com/show
example.com/show/page/2
example.com/show/page/3
function b2e_pre_get_posts( $query )
{
    $target_page = 'show';                             // EDIT to your needs

    if (    ! is_admin()                               // front-end only
         && $query->is_main_query()                    // main query only
         && $target_page === $query->get( 'pagename' ) // matching pagename only
    ) {
        // modify query_vars:
        $query->set( 'post_type',      'post'                 );  // override 'post_type'
        $query->set( 'pagename',       null                   );  // override 'pagename'
        $query->set( 'posts_per_page', 10                     );
        $query->set( 'meta_key',       'wpb_post_views_count' );
        $query->set( 'orderby',        'meta_value_num'       );
        $query->set( 'order',          'DESC'                 );

        // Support for paging
        $query->is_singular = 0;

        // custom page template
        add_filter( 'template_include', 'b2e_template_include', 99 );
    }
}

add_action( 'pre_get_posts', 'b2e_pre_get_posts' );
function b2e_template_include( $template )
{
    $target_tpl = 'tpl_show.php'; // EDIT to your needs

    remove_filter( 'template_include', 'b2e_template_include', 99 );

    $new_template = locate_template( array( $target_tpl ) );

    if ( ! empty( $new_template ) )
        $template = $new_template; ;

    return $template;
}
tpl_show.php

Also removed the is_page() check within the pre_get_posts hook, since it might still give some irregularities in some cases. The reason is that the query-object is not always available. This is being worked on, see e.g. #27015. There are workarounds possible if we want to use the is_page() or is_front_page().

Ceate a page called for example Show with the slug:

Create a custom page template:

I constructed the following table, just to get a better overview of some of the properties and query varaiables of the main WP_Query object, for a given slug:

I updated the answer and removed the query-object part modification, based on the suggestion from @PieterGoosen, since it could e.g. break the breadcrumbs on his setup.

I've been playing with the pre_get_posts hook and here's one idea

It's interesting to note that the pagination in WP_Query depends on the nopaging not being set and the current page not being singular (from the 4.4 source):

No problem. Enjoy the rest of your Sunday. Last day to "relax" before everything goes back to normal tommorrow

Thanks @andy, it was an interesting puzzle ;-)

Thanks for the update @PieterGoosen and generous bounty. I just updated the answer. I guess I was trying to remove the page identity when I originally posted it, but I've now skipped that part, thanks to your suggestion.

We construct the following pre_get_posts action callback:

We could have used other filter/hooks, but here we used pre_get_posts as mentioned by the OP.

Will award the bounty to you as soon as I can, unfortunately I cannot award the bounty within 24 hours of starting one. Enjoy ;

located in the current theme directory.

where we can see that the LIMIT part of the generated SQL query is within the conditional check. This explains why we modify the is_singular property above.

Note
Rectangle 27 0

php WordPress pre_get_posts in place of query_posts on pages?


$query = new PreGetPostsForPages(
    251,       // Page ID we will target
    'content', //Template part which will be used to display posts, name should be without .php extension 
    true,      // Should get_template_part support post formats
    false,     // Should the page object be excluded from the loop
    [          // Array of valid arguments that will be passed to WP_Query/pre_get_posts
        'post_type'      => 'post', 
        'posts_per_page' => 2
    ] 
);
$query->init();
add_action( 'pregetgostsforgages_counter_before_template_part', function ( $counter )
{
    $class = $counter%2  ? ' right' : ' left';
    echo '<div class="entry-column' . $class . '">';
});

add_action( 'pregetgostsforgages_counter_after_template_part', function ( $counter )
{
    echo '</div>';
});

add_action( 'pregetgostsforgages_after_loop_pagination', function ( \WP_Query $q )
{
    paginated_numbers();    
});
class PreGetPostsForPages
{
    /**
     * @var string|int $pageID
     * @access protected     
     * @since 1.0.0
     */
    protected $pageID;

    /**
     * @var string $templatePart
     * @access protected     
     * @since 1.0.0
     */
    protected $templatePart;

    /**
     * @var bool $postFormatSupport
     * @access protected     
     * @since 1.0.0
     */
    protected $postFormatSupport;

    /**
     * @var bool $removePageFromLoop
     * @access protected     
     * @since 1.0.0
     */
    protected $removePageFromLoop;

    /**
     * @var array $args
     * @access protected     
     * @since 1.0.0
     */
    protected $args;

    /**
     * @var array $mergedArgs
     * @access protected     
     * @since 1.0.0
     */
    protected $mergedArgs = [];

    /**
     * @var NULL|\stdClass $injectorQuery
     * @access protected     
     * @since 1.0.0
     */
    protected $injectorQuery = NULL;

    /**
     * @var int $validatedPageID
     * @access protected     
     * @since 1.0.0
     */
    protected $validatedPageID = 0;

    /** 
     * Constructor method
     *
     * @param string|int $pageID The ID of the page we would like to target
     * @param string $templatePart The template part which should be used to display posts
     * @param string $postFormatSupport Should get_template_part support post format specific template parts
     * @param bool $removePageFromLoop Should the page content be displayed or not
     * @param array $args An array of valid arguments compatible with WP_Query
     *
     * @since 1.0.0
     */      
    public function __construct( 
        $pageID             = NULL,
        $templatePart       = NULL,
        $postFormatSupport  = false,
        $removePageFromLoop = false,
        $args               = [] 
    ) {
        $this->pageID             = $pageID;
        $this->templatePart       = $templatePart;
        $this->postFormatSupport  = $postFormatSupport;
        $this->removePageFromLoop = $removePageFromLoop;
        $this->args               = $args;
    }

    /**
     * Public method init()
     *
     * The init method will be use to initialize our pre_get_posts action
     *
     * @since 1.0.0
     */
    public function init()
    {
        // Initialise our pre_get_posts action
        add_action( 'pre_get_posts', [$this, 'preGetPosts'] );
    }

    /**
     * Private method validatePageID()
     *
     * Validates the page ID passed
     *
     * @since 1.0.0
     */
    private function validatePageID()
    {
        $validatedPageID = filter_var( $this->pageID, FILTER_VALIDATE_INT );
        $this->validatedPageID = $validatedPageID;
    }

    /**
     * Private method mergedArgs()
     *
     * Merge the default args with the user passed args
     *
     * @since 1.0.0
     */
    private function mergedArgs()
    {
        // Set default arguments
        if ( get_query_var( 'paged' ) ) {
            $currentPage = get_query_var( 'paged' );
        } elseif ( get_query_var( 'page' ) ) {
            $currentPage = get_query_var( 'page' );
        } else {
            $currentPage = 1;
        }
        $default = [
            'suppress_filters'    => true,
            'ignore_sticky_posts' => 1,
            'paged'               => $currentPage,
            'posts_per_page'      => get_option( 'posts_per_page' ), // Set posts per page here to set the LIMIT clause etc
            'nopaging'            => false
        ];    
        $mergedArgs = wp_parse_args( (array) $this->args, $default );
        $this->mergedArgs = $mergedArgs;
    }

    /**
     * Public method preGetPosts()
     *
     * This is the callback method which will be hooked to the 
     * pre_get_posts action hook. This method will be used to alter
     * the main query on the page specified by ID.
     *
     * @param \stdClass WP_Query The query object passed by reference
     * @since 1.0.0
     */
    public function preGetPosts( \WP_Query $q )
    {
        if (    !is_admin() // Only target the front end
             && $q->is_main_query() // Only target the main query
             && $q->is_page( filter_var( $this->validatedPageID, FILTER_VALIDATE_INT ) ) // Only target our specified page
        ) {
            // Remove the pre_get_posts action to avoid unexpected issues
            remove_action( current_action(), [$this, __METHOD__] );

            // METHODS:
            // Initialize our method which will return the validated page ID
            $this->validatePageID();
            // Initiale our mergedArgs() method
            $this->mergedArgs();
            // Initiale our custom query method
            $this->injectorQuery();

            /**
             * We need to alter a couple of things here in order for this to work
             * - Set posts_per_page to the user set value in order for the query to
             *   to properly calculate the $max_num_pages property for pagination
             * - Set the $found_posts property of the main query to the $found_posts
             *   property of our custom query we will be using to inject posts
             * - Set the LIMIT clause to the SQL query. By default, on pages, `is_singular` 
             *   returns true on pages which removes the LIMIT clause from the SQL query.
             *   We need the LIMIT clause because an empty limit clause inhibits the calculation
             *   of the $max_num_pages property which we need for pagination
             */
            if (    $this->mergedArgs['posts_per_page'] 
                 && true !== $this->mergedArgs['nopaging']
            ) {
                $q->set( 'posts_per_page', $this->mergedArgs['posts_per_page'] );
            } elseif ( true === $this->mergedArgs['nopaging'] ) {
                $q->set( 'posts_per_page', -1 );
            }

            // FILTERS:
            add_filter( 'found_posts', [$this, 'foundPosts'], PHP_INT_MAX, 2 );
            add_filter( 'post_limits', [$this, 'postLimits']);

            // ACTIONS:
            /**
             * We can now add all our actions that we will be using to inject our custom
             * posts into the main query. We will not be altering the main query or the 
             * main query's $posts property as we would like to keep full integrity of the 
             * $post, $posts globals as well as $wp_query->post. For this reason we will use
             * post injection
             */     
            add_action( 'loop_start', [$this, 'loopStart'], 1 );
            add_action( 'loop_end',   [$this, 'loopEnd'],   1 );
        }    
    }    

    /**
     * Public method injectorQuery
     *
     * This will be the method which will handle our custom
     * query which will be used to 
     * - return the posts that should be injected into the main
     *   query according to the arguments passed
     * - alter the $found_posts property of the main query to make
     *   pagination work 
     *
     * @link https://codex.wordpress.org/Class_Reference/WP_Query
     * @since 1.0.0
     * @return \stdClass $this->injectorQuery
     */
    public function injectorQuery()
    {
        //Define our custom query
        $injectorQuery = new \WP_Query( $this->mergedArgs );

        $this->injectorQuery = $injectorQuery;

        return $this->injectorQuery;
    }

    /**
     * Public callback method foundPosts()
     * 
     * We need to set found_posts in the main query to the $found_posts
     * property of the custom query in order for the main query to correctly 
     * calculate $max_num_pages for pagination
     *
     * @param string $found_posts Passed by reference by the filter
     * @param stdClass \WP_Query Sq The current query object passed by refence
     * @since 1.0.0
     * @return $found_posts
     */
    public function foundPosts( $found_posts, \WP_Query $q )
    {
        if ( !$q->is_main_query() )
            return $found_posts;

        remove_filter( current_filter(), [$this, __METHOD__] );

        // Make sure that $this->injectorQuery actually have a value and is not NULL
        if (    $this->injectorQuery instanceof \WP_Query 
             && 0 != $this->injectorQuery->found_posts
        )
            return $found_posts = $this->injectorQuery->found_posts;

        return $found_posts;
    }

    /**
     * Public callback method postLimits()
     *
     * We need to set the LIMIT clause as it it is removed on pages due to 
     * is_singular returning true. Witout the limit clause, $max_num_pages stays
     * set 0 which avoids pagination. 
     *
     * We will also leave the offset part of the LIMIT cluase to 0 to avoid paged
     * pages returning 404's
     *
     * @param string $limits Passed by reference in the filter
     * @since 1.0.0
     * @return $limits
     */
    public function postLimits( $limits )
    {
        $posts_per_page = (int) $this->mergedArgs['posts_per_page'];
        if (    $posts_per_page
             && -1   !=  $posts_per_page // Make sure that posts_per_page is not set to return all posts
             && true !== $this->mergedArgs['nopaging'] // Make sure that nopaging is not set to true
        ) {
            $limits = "LIMIT 0, $posts_per_page"; // Leave offset at 0 to avoid 404 on paged pages
        }

        return $limits;
    }

    /**
     * Public callback method loopStart()
     *
     * Callback function which will be hooked to the loop_start action hook
     *
     * @param \stdClass \WP_Query $q Query object passed by reference
     * @since 1.0.0
     */
    public function loopStart( \WP_Query $q )
    {
        /**
         * Although we run this action inside our preGetPosts methods and
         * and inside a main query check, we need to redo the check here aswell
         * because failing to do so sets our div in the custom query output as well
         */

        if ( !$q->is_main_query() )
            return;

        /** 
         * Add inline style to hide the page content from the loop
         * whenever $removePageFromLoop is set to true. You can
         * alternatively alter the page template in a child theme by removing
         * everything inside the loop, but keeping the loop
         * Example of how your loop should look like:
         *     while ( have_posts() ) {
         *     the_post();
         *         // Add nothing here
         *     }
         */
        if ( true === $this->removePageFromLoop )
            echo '<div style="display:none">';
    }   

    /**
     * Public callback method loopEnd()
     *
     * Callback function which will be hooked to the loop_end action hook
     *
     * @param \stdClass \WP_Query $q Query object passed by reference
     * @since 1.0.0
     */
    public function loopEnd( \WP_Query $q )
    {  
        /**
         * Although we run this action inside our preGetPosts methods and
         * and inside a main query check, we need to redo the check here as well
         * because failing to do so sets our custom query into an infinite loop
         */
        if ( !$q->is_main_query() )
            return;

        // See the note in the loopStart method  
        if ( true === $this->removePageFromLoop )
            echo '</div>';

        //Make sure that $this->injectorQuery actually have a value and is not NULL
        if ( !$this->injectorQuery instanceof \WP_Query )
            return; 

        // Setup a counter as wee need to run the custom query only once    
        static $count = 0;    

        /**
         * Only run the custom query on the first run of the loop. Any consecutive
         * runs (like if the user runs the loop again), the custom posts won't show.
         */
        if ( 0 === (int) $count ) {      
            // We will now add our custom posts on loop_end
            $this->injectorQuery->rewind_posts();

            // Create our loop
            if ( $this->injectorQuery->have_posts() ) {

                /**
                 * Fires before the loop to add pagination.
                 *
                 * @since 1.0.0
                 *
                 * @param \stdClass $this->injectorQuery Current object (passed by reference).
                 */
                do_action( 'pregetgostsforgages_before_loop_pagination', $this->injectorQuery );


                // Add a static counter for those who need it
                static $counter = 0;

                while ( $this->injectorQuery->have_posts() ) {
                    $this->injectorQuery->the_post(); 

                    /**
                     * Fires before get_template_part.
                     *
                     * @since 1.0.0
                     *
                     * @param int $counter (passed by reference).
                     */
                    do_action( 'pregetgostsforgages_counter_before_template_part', $counter );

                    /**
                     * Fires before get_template_part.
                     *
                     * @since 1.0.0
                     *
                     * @param \stdClass $this->injectorQuery-post Current post object (passed by reference).
                     * @param \stdClass $this->injectorQuery Current object (passed by reference).
                     */
                    do_action( 'pregetgostsforgages_current_post_and_object', $this->injectorQuery->post, $this->injectorQuery );

                    /** 
                     * Load our custom template part as set by the user
                     * 
                     * We will also add template support for post formats. If $this->postFormatSupport
                     * is set to true, get_post_format() will be automatically added in get_template part
                     *
                     * If you have a template called content-video.php, you only need to pass 'content'
                     * to $template part and then set $this->postFormatSupport to true in order to load
                     * content-video.php for video post format posts
                     */
                    $part = '';
                    if ( true === $this->postFormatSupport )
                        $part = get_post_format( $this->injectorQuery->post->ID ); 

                    get_template_part( 
                        filter_var( $this->templatePart, FILTER_SANITIZE_STRING ), 
                        $part
                    );

                    /**
                     * Fires after get_template_part.
                     *
                     * @since 1.0.0
                     *
                     * @param int $counter (passed by reference).
                     */
                    do_action( 'pregetgostsforgages_counter_after_template_part', $counter );

                    $counter++; //Update the counter
                }

                wp_reset_postdata();

                /**
                 * Fires after the loop to add pagination.
                 *
                 * @since 1.0.0
                 *
                 * @param \stdClass $this->injectorQuery Current object (passed by reference).
                 */
                do_action( 'pregetgostsforgages_after_loop_pagination', $this->injectorQuery );
            }
        }

        // Update our static counter
        $count++;       
    }
}
pregetgostsforgages_after_loop_pagination
pregetgostsforgages_before_loop_pagination
pregetgostsforgages_counter_after_template_part
pregetgostsforgages_counter_before_template_part
pregetgostsforgages_current_post_and_object

As I said, there are a few actions in the injector query in order to add pagination or custom styling. Here I added pagination after the loop using my own pagination function from the linked answer. Also, using the build in counter, I added a div to to display my posts in two colums.

At this stage I'm using CSS to hide the page object through the loop_start and loop_end actions as I cannot find another way of doing this. The downside with this method is that anything hooked to the_post action hook inside the main query will also be hidden by default if you hide the page object.

By using post injection, I'm able to keep full post integrity, so $wp_the_query->post, $wp_query->post, $posts and $post stays constant throughout the template, they all only holds the current page object as is the case with true pages. This way, functions like breadcrumbs still think that the current page is a true page and not some kind of archive

Custom counter which counts the posts in the loop. These actions can be used to alter how posts are displayed inside the loop according to post number

Everything works as expected on static front pages together with my pagination function without having to do no modifications

General hook to access the query object and current post object

Here are the actions I used

I had to alter the main query slightly (through filters and actions) to adjust for pagination though, but we will come to that.

I have also used get_template_part() in order to load a template part which will be used to display the posts on. Most themes today make use of template parts, that makes this very useful in the class. If your theme uses content.php, you can simply pass content to $templatePart to load content.php.

If you need post format support for template parts, it is easy, you can still just pass content to $templatePart and simply set $postFormatSupport to true and a template part content-video.php will be loaded for a post with a post format of video

In order to accomplish post injection, I used a custom query to return the posts needed for injection. I also used the custom query's $found_pages property to adjust that of the main query to get pagination working from the main query. Posts are injected into the main query through the loop_end action.

In order to make the custom query accessible and usable outside the class, I introduced a couple of actions.

Note that pagination is set by the main query, not the injector query, so build-in functions like the_posts_pagination() should also work.

Pagination hooks in order to hook pagination funtions:

The PreGetPostsForPages class can be improved and should be properly namespaced as well Although you can simply drop this in your theme's functions file, it would be better to drop this into a custom plugin.

The current page object is available to display as a post by using the default loop on the page, separate and on top of the injected posts. If you do not need this, you can simply set $removePageFromLoop to true, this will hide the page content from being displayed.

The following changes was done to the main query through the respective filters and actions

There are more pro's which I cannot think of now, but these are the important ones

These hooks gives you a total hands-off experience as you do not need to change anything in the page template itself, which was my original intention from the start. A page can completely be altered from a plugin or a function file which makes this very dynamic

This might seem like a really lot of overheads, and it might be, but the pro's outweigh the con's big time

This takes care of pagination and any issue that might arise from the post injection

Use, modify and abuse as you see fit. The code is well commented, so it should be easy to follow and adjust

What I tried to do here was to rather use post injection than completely altering the main query and getting stuck with all the above issues, like directly altering globals, the global value issue and reassigning page templates.

You can now initiate the class (also in your plugin or functions file) as follow to target the page with ID 251 on which we will show 2 posts per page from the post post type

Note
Rectangle 27 0

php WordPress pre_get_posts in place of query_posts on pages?


$query = new PreGetPostsForPages(
    251,       // Page ID we will target
    'content', //Template part which will be used to display posts, name should be without .php extension 
    true,      // Should get_template_part support post formats
    false,     // Should the page object be excluded from the loop
    [          // Array of valid arguments that will be passed to WP_Query/pre_get_posts
        'post_type'      => 'post', 
        'posts_per_page' => 2
    ] 
);
$query->init();
add_action( 'pregetgostsforgages_counter_before_template_part', function ( $counter )
{
    $class = $counter%2  ? ' right' : ' left';
    echo '<div class="entry-column' . $class . '">';
});

add_action( 'pregetgostsforgages_counter_after_template_part', function ( $counter )
{
    echo '</div>';
});

add_action( 'pregetgostsforgages_after_loop_pagination', function ( \WP_Query $q )
{
    paginated_numbers();    
});
class PreGetPostsForPages
{
    /**
     * @var string|int $pageID
     * @access protected     
     * @since 1.0.0
     */
    protected $pageID;

    /**
     * @var string $templatePart
     * @access protected     
     * @since 1.0.0
     */
    protected $templatePart;

    /**
     * @var bool $postFormatSupport
     * @access protected     
     * @since 1.0.0
     */
    protected $postFormatSupport;

    /**
     * @var bool $removePageFromLoop
     * @access protected     
     * @since 1.0.0
     */
    protected $removePageFromLoop;

    /**
     * @var array $args
     * @access protected     
     * @since 1.0.0
     */
    protected $args;

    /**
     * @var array $mergedArgs
     * @access protected     
     * @since 1.0.0
     */
    protected $mergedArgs = [];

    /**
     * @var NULL|\stdClass $injectorQuery
     * @access protected     
     * @since 1.0.0
     */
    protected $injectorQuery = NULL;

    /**
     * @var int $validatedPageID
     * @access protected     
     * @since 1.0.0
     */
    protected $validatedPageID = 0;

    /** 
     * Constructor method
     *
     * @param string|int $pageID The ID of the page we would like to target
     * @param string $templatePart The template part which should be used to display posts
     * @param string $postFormatSupport Should get_template_part support post format specific template parts
     * @param bool $removePageFromLoop Should the page content be displayed or not
     * @param array $args An array of valid arguments compatible with WP_Query
     *
     * @since 1.0.0
     */      
    public function __construct( 
        $pageID             = NULL,
        $templatePart       = NULL,
        $postFormatSupport  = false,
        $removePageFromLoop = false,
        $args               = [] 
    ) {
        $this->pageID             = $pageID;
        $this->templatePart       = $templatePart;
        $this->postFormatSupport  = $postFormatSupport;
        $this->removePageFromLoop = $removePageFromLoop;
        $this->args               = $args;
    }

    /**
     * Public method init()
     *
     * The init method will be use to initialize our pre_get_posts action
     *
     * @since 1.0.0
     */
    public function init()
    {
        // Initialise our pre_get_posts action
        add_action( 'pre_get_posts', [$this, 'preGetPosts'] );
    }

    /**
     * Private method validatePageID()
     *
     * Validates the page ID passed
     *
     * @since 1.0.0
     */
    private function validatePageID()
    {
        $validatedPageID = filter_var( $this->pageID, FILTER_VALIDATE_INT );
        $this->validatedPageID = $validatedPageID;
    }

    /**
     * Private method mergedArgs()
     *
     * Merge the default args with the user passed args
     *
     * @since 1.0.0
     */
    private function mergedArgs()
    {
        // Set default arguments
        if ( get_query_var( 'paged' ) ) {
            $currentPage = get_query_var( 'paged' );
        } elseif ( get_query_var( 'page' ) ) {
            $currentPage = get_query_var( 'page' );
        } else {
            $currentPage = 1;
        }
        $default = [
            'suppress_filters'    => true,
            'ignore_sticky_posts' => 1,
            'paged'               => $currentPage,
            'posts_per_page'      => get_option( 'posts_per_page' ), // Set posts per page here to set the LIMIT clause etc
            'nopaging'            => false
        ];    
        $mergedArgs = wp_parse_args( (array) $this->args, $default );
        $this->mergedArgs = $mergedArgs;
    }

    /**
     * Public method preGetPosts()
     *
     * This is the callback method which will be hooked to the 
     * pre_get_posts action hook. This method will be used to alter
     * the main query on the page specified by ID.
     *
     * @param \stdClass WP_Query The query object passed by reference
     * @since 1.0.0
     */
    public function preGetPosts( \WP_Query $q )
    {
        if (    !is_admin() // Only target the front end
             && $q->is_main_query() // Only target the main query
             && $q->is_page( filter_var( $this->validatedPageID, FILTER_VALIDATE_INT ) ) // Only target our specified page
        ) {
            // Remove the pre_get_posts action to avoid unexpected issues
            remove_action( current_action(), [$this, __METHOD__] );

            // METHODS:
            // Initialize our method which will return the validated page ID
            $this->validatePageID();
            // Initiale our mergedArgs() method
            $this->mergedArgs();
            // Initiale our custom query method
            $this->injectorQuery();

            /**
             * We need to alter a couple of things here in order for this to work
             * - Set posts_per_page to the user set value in order for the query to
             *   to properly calculate the $max_num_pages property for pagination
             * - Set the $found_posts property of the main query to the $found_posts
             *   property of our custom query we will be using to inject posts
             * - Set the LIMIT clause to the SQL query. By default, on pages, `is_singular` 
             *   returns true on pages which removes the LIMIT clause from the SQL query.
             *   We need the LIMIT clause because an empty limit clause inhibits the calculation
             *   of the $max_num_pages property which we need for pagination
             */
            if (    $this->mergedArgs['posts_per_page'] 
                 && true !== $this->mergedArgs['nopaging']
            ) {
                $q->set( 'posts_per_page', $this->mergedArgs['posts_per_page'] );
            } elseif ( true === $this->mergedArgs['nopaging'] ) {
                $q->set( 'posts_per_page', -1 );
            }

            // FILTERS:
            add_filter( 'found_posts', [$this, 'foundPosts'], PHP_INT_MAX, 2 );
            add_filter( 'post_limits', [$this, 'postLimits']);

            // ACTIONS:
            /**
             * We can now add all our actions that we will be using to inject our custom
             * posts into the main query. We will not be altering the main query or the 
             * main query's $posts property as we would like to keep full integrity of the 
             * $post, $posts globals as well as $wp_query->post. For this reason we will use
             * post injection
             */     
            add_action( 'loop_start', [$this, 'loopStart'], 1 );
            add_action( 'loop_end',   [$this, 'loopEnd'],   1 );
        }    
    }    

    /**
     * Public method injectorQuery
     *
     * This will be the method which will handle our custom
     * query which will be used to 
     * - return the posts that should be injected into the main
     *   query according to the arguments passed
     * - alter the $found_posts property of the main query to make
     *   pagination work 
     *
     * @link https://codex.wordpress.org/Class_Reference/WP_Query
     * @since 1.0.0
     * @return \stdClass $this->injectorQuery
     */
    public function injectorQuery()
    {
        //Define our custom query
        $injectorQuery = new \WP_Query( $this->mergedArgs );

        $this->injectorQuery = $injectorQuery;

        return $this->injectorQuery;
    }

    /**
     * Public callback method foundPosts()
     * 
     * We need to set found_posts in the main query to the $found_posts
     * property of the custom query in order for the main query to correctly 
     * calculate $max_num_pages for pagination
     *
     * @param string $found_posts Passed by reference by the filter
     * @param stdClass \WP_Query Sq The current query object passed by refence
     * @since 1.0.0
     * @return $found_posts
     */
    public function foundPosts( $found_posts, \WP_Query $q )
    {
        if ( !$q->is_main_query() )
            return $found_posts;

        remove_filter( current_filter(), [$this, __METHOD__] );

        // Make sure that $this->injectorQuery actually have a value and is not NULL
        if (    $this->injectorQuery instanceof \WP_Query 
             && 0 != $this->injectorQuery->found_posts
        )
            return $found_posts = $this->injectorQuery->found_posts;

        return $found_posts;
    }

    /**
     * Public callback method postLimits()
     *
     * We need to set the LIMIT clause as it it is removed on pages due to 
     * is_singular returning true. Witout the limit clause, $max_num_pages stays
     * set 0 which avoids pagination. 
     *
     * We will also leave the offset part of the LIMIT cluase to 0 to avoid paged
     * pages returning 404's
     *
     * @param string $limits Passed by reference in the filter
     * @since 1.0.0
     * @return $limits
     */
    public function postLimits( $limits )
    {
        $posts_per_page = (int) $this->mergedArgs['posts_per_page'];
        if (    $posts_per_page
             && -1   !=  $posts_per_page // Make sure that posts_per_page is not set to return all posts
             && true !== $this->mergedArgs['nopaging'] // Make sure that nopaging is not set to true
        ) {
            $limits = "LIMIT 0, $posts_per_page"; // Leave offset at 0 to avoid 404 on paged pages
        }

        return $limits;
    }

    /**
     * Public callback method loopStart()
     *
     * Callback function which will be hooked to the loop_start action hook
     *
     * @param \stdClass \WP_Query $q Query object passed by reference
     * @since 1.0.0
     */
    public function loopStart( \WP_Query $q )
    {
        /**
         * Although we run this action inside our preGetPosts methods and
         * and inside a main query check, we need to redo the check here aswell
         * because failing to do so sets our div in the custom query output as well
         */

        if ( !$q->is_main_query() )
            return;

        /** 
         * Add inline style to hide the page content from the loop
         * whenever $removePageFromLoop is set to true. You can
         * alternatively alter the page template in a child theme by removing
         * everything inside the loop, but keeping the loop
         * Example of how your loop should look like:
         *     while ( have_posts() ) {
         *     the_post();
         *         // Add nothing here
         *     }
         */
        if ( true === $this->removePageFromLoop )
            echo '<div style="display:none">';
    }   

    /**
     * Public callback method loopEnd()
     *
     * Callback function which will be hooked to the loop_end action hook
     *
     * @param \stdClass \WP_Query $q Query object passed by reference
     * @since 1.0.0
     */
    public function loopEnd( \WP_Query $q )
    {  
        /**
         * Although we run this action inside our preGetPosts methods and
         * and inside a main query check, we need to redo the check here as well
         * because failing to do so sets our custom query into an infinite loop
         */
        if ( !$q->is_main_query() )
            return;

        // See the note in the loopStart method  
        if ( true === $this->removePageFromLoop )
            echo '</div>';

        //Make sure that $this->injectorQuery actually have a value and is not NULL
        if ( !$this->injectorQuery instanceof \WP_Query )
            return; 

        // Setup a counter as wee need to run the custom query only once    
        static $count = 0;    

        /**
         * Only run the custom query on the first run of the loop. Any consecutive
         * runs (like if the user runs the loop again), the custom posts won't show.
         */
        if ( 0 === (int) $count ) {      
            // We will now add our custom posts on loop_end
            $this->injectorQuery->rewind_posts();

            // Create our loop
            if ( $this->injectorQuery->have_posts() ) {

                /**
                 * Fires before the loop to add pagination.
                 *
                 * @since 1.0.0
                 *
                 * @param \stdClass $this->injectorQuery Current object (passed by reference).
                 */
                do_action( 'pregetgostsforgages_before_loop_pagination', $this->injectorQuery );


                // Add a static counter for those who need it
                static $counter = 0;

                while ( $this->injectorQuery->have_posts() ) {
                    $this->injectorQuery->the_post(); 

                    /**
                     * Fires before get_template_part.
                     *
                     * @since 1.0.0
                     *
                     * @param int $counter (passed by reference).
                     */
                    do_action( 'pregetgostsforgages_counter_before_template_part', $counter );

                    /**
                     * Fires before get_template_part.
                     *
                     * @since 1.0.0
                     *
                     * @param \stdClass $this->injectorQuery-post Current post object (passed by reference).
                     * @param \stdClass $this->injectorQuery Current object (passed by reference).
                     */
                    do_action( 'pregetgostsforgages_current_post_and_object', $this->injectorQuery->post, $this->injectorQuery );

                    /** 
                     * Load our custom template part as set by the user
                     * 
                     * We will also add template support for post formats. If $this->postFormatSupport
                     * is set to true, get_post_format() will be automatically added in get_template part
                     *
                     * If you have a template called content-video.php, you only need to pass 'content'
                     * to $template part and then set $this->postFormatSupport to true in order to load
                     * content-video.php for video post format posts
                     */
                    $part = '';
                    if ( true === $this->postFormatSupport )
                        $part = get_post_format( $this->injectorQuery->post->ID ); 

                    get_template_part( 
                        filter_var( $this->templatePart, FILTER_SANITIZE_STRING ), 
                        $part
                    );

                    /**
                     * Fires after get_template_part.
                     *
                     * @since 1.0.0
                     *
                     * @param int $counter (passed by reference).
                     */
                    do_action( 'pregetgostsforgages_counter_after_template_part', $counter );

                    $counter++; //Update the counter
                }

                wp_reset_postdata();

                /**
                 * Fires after the loop to add pagination.
                 *
                 * @since 1.0.0
                 *
                 * @param \stdClass $this->injectorQuery Current object (passed by reference).
                 */
                do_action( 'pregetgostsforgages_after_loop_pagination', $this->injectorQuery );
            }
        }

        // Update our static counter
        $count++;       
    }
}
pregetgostsforgages_after_loop_pagination
pregetgostsforgages_before_loop_pagination
pregetgostsforgages_counter_after_template_part
pregetgostsforgages_counter_before_template_part
pregetgostsforgages_current_post_and_object

As I said, there are a few actions in the injector query in order to add pagination or custom styling. Here I added pagination after the loop using my own pagination function from the linked answer. Also, using the build in counter, I added a div to to display my posts in two colums.

At this stage I'm using CSS to hide the page object through the loop_start and loop_end actions as I cannot find another way of doing this. The downside with this method is that anything hooked to the_post action hook inside the main query will also be hidden by default if you hide the page object.

By using post injection, I'm able to keep full post integrity, so $wp_the_query->post, $wp_query->post, $posts and $post stays constant throughout the template, they all only holds the current page object as is the case with true pages. This way, functions like breadcrumbs still think that the current page is a true page and not some kind of archive

Custom counter which counts the posts in the loop. These actions can be used to alter how posts are displayed inside the loop according to post number

Everything works as expected on static front pages together with my pagination function without having to do no modifications

General hook to access the query object and current post object

Here are the actions I used

I had to alter the main query slightly (through filters and actions) to adjust for pagination though, but we will come to that.

I have also used get_template_part() in order to load a template part which will be used to display the posts on. Most themes today make use of template parts, that makes this very useful in the class. If your theme uses content.php, you can simply pass content to $templatePart to load content.php.

If you need post format support for template parts, it is easy, you can still just pass content to $templatePart and simply set $postFormatSupport to true and a template part content-video.php will be loaded for a post with a post format of video

In order to accomplish post injection, I used a custom query to return the posts needed for injection. I also used the custom query's $found_pages property to adjust that of the main query to get pagination working from the main query. Posts are injected into the main query through the loop_end action.

In order to make the custom query accessible and usable outside the class, I introduced a couple of actions.

Note that pagination is set by the main query, not the injector query, so build-in functions like the_posts_pagination() should also work.

Pagination hooks in order to hook pagination funtions:

The PreGetPostsForPages class can be improved and should be properly namespaced as well Although you can simply drop this in your theme's functions file, it would be better to drop this into a custom plugin.

The current page object is available to display as a post by using the default loop on the page, separate and on top of the injected posts. If you do not need this, you can simply set $removePageFromLoop to true, this will hide the page content from being displayed.

The following changes was done to the main query through the respective filters and actions

There are more pro's which I cannot think of now, but these are the important ones

These hooks gives you a total hands-off experience as you do not need to change anything in the page template itself, which was my original intention from the start. A page can completely be altered from a plugin or a function file which makes this very dynamic

This might seem like a really lot of overheads, and it might be, but the pro's outweigh the con's big time

This takes care of pagination and any issue that might arise from the post injection

Use, modify and abuse as you see fit. The code is well commented, so it should be easy to follow and adjust

What I tried to do here was to rather use post injection than completely altering the main query and getting stuck with all the above issues, like directly altering globals, the global value issue and reassigning page templates.

You can now initiate the class (also in your plugin or functions file) as follow to target the page with ID 251 on which we will show 2 posts per page from the post post type

Note