Custom query with custom filtering returning incorrect results

Your query syntax looked good, although you could instead add a direct meta/taxonomy query clause array such as $meta_query[] = array( 'key' => '_price', ... ) instead of $meta_query[] = array( 'relation' => 'AND', array( 'key' => '_price', ... ) ).

However, I spotted 2 main issues with your code:

Issue 1: Your custom pagination function will not work correctly with secondary WP_Query queries.

Because the function (wordpress_numeric_post_nav) was intended only for the main WordPress query which is referenced using the global $wp_query object.

So if you want that function to work with secondary or custom WP_Query queries, then you need to adjust your function so that it supports custom WP_Query instances like your $products object.

Here’s a simple modification you can try:

  1. Change the function wordpress_numeric_post_nav() to:

    function wordpress_numeric_post_nav( $wp_query = null )
    
  2. Then in that function, change the global $wp_query; to:

    if ( ! is_a( $wp_query, 'WP_Query' ) ) {
        global $wp_query;
    }
    

So with that, you’ll display the pagination like so:

echo wordpress_numeric_post_nav( $products );

Issue 2: You should use the pre_get_posts action and not creating a secondary WP_Query query and loop.

The main query also uses paged as the pagination query var (which is read from the URL of, or matching rewrite rule for the current page), hence it will conflict with the pagination for secondary WP_Query queries on archive pages like your product post type archive, because the main query could have less number of pages than secondary queries, leading to 404 errors.

So although 404 error isn’t or may not be the problem here, I strongly suggest you to use the pre_get_posts action to apply your custom meta and taxonomy filters.

So just remove that $products = new WP_Query($args); (and the variables, etc. used for that piece of code) from your template (archive-product.php) and then:

  1. In the theme functions file, add this snippet:

    add_action( 'pre_get_posts', 'my_product_archive_pre_get_posts' );
    function my_product_archive_pre_get_posts( $query ) {
        // Checks whether we are on the product archive page and that the current
        // WP_Query instance is the main query. We also don't want to affect admin
        // posts queries, hence we do `! is_admin()` check.
        if ( ! is_admin() && is_post_type_archive( 'product' ) &&
            $query->is_main_query()
        ) {
            $tax_query  = array( 'relation' => 'AND' );
            $meta_query = array( 'relation' => 'AND' );
    
            // Sorts the posts by the stock status and then price.
            $meta_query[] = array(
                'relation'      => 'AND',
                '_stock_status' => array(
                    'key'     => '_stock_status',
                    'compare' => 'EXISTS',
                ),
                '_price'        => array(
                    'key'     => '_price',
                    'compare' => 'EXISTS',
                ),
            );
    
            // Filter by a specific price range.
            if ( ! empty( $_GET['product-price'] ) ) {
                $range  = sanitize_text_field( $_GET['product-price'] );
                $prices = array_map( 'intval', explode( ':', $range ) );
    
                $meta_query[] = array(
                    'key'     => '_price',
                    'value'   => $prices,
                    'type'    => 'NUMERIC',
                    'compare' => 'BETWEEN',
                );
            }
    
            // Filter by a specific product type.
            if ( ! empty( $_GET['product-type'] ) ) {
                $tax_query[] = array(
                    'taxonomy' => 'product_cat',
                    'field'    => 'slug',
                    'terms'    => sanitize_text_field( $_GET['product-type'] ),
                );
            }
    
            // Filter by a specific product brand.
            if ( ! empty( $_GET['product-brand'] ) ) {
                $tax_query[] = array(
                    'taxonomy' => 'pa_brands',
                    'field'    => 'slug',
                    'terms'    => sanitize_text_field( $_GET['product-brand'] ),
                );
            }
    
            // Now modify the main query's arguments.
            $query->set( 'posts_per_page', 16 );
            $query->set( 'tax_query', $tax_query );
            $query->set( 'meta_query', $meta_query );
            $query->set(
                'orderby',
                array(
                    '_stock_status' => 'ASC',
                    '_price'        => 'DESC',
                )
            );
        }
    }
    

    Hint: You can actually just use the '<taxonomy>' => '<term slug>' format, hence you can remove the $tax_query parts and do something like:

    // Filter by a specific product type.
    if ( ! empty( $_GET['product-type'] ) ) {
        $query->set( 'product_cat', sanitize_text_field( $_GET['product-type'] ) );
    }
    
    // Filter by a specific product brand.
    if ( ! empty( $_GET['product-brand'] ) ) {
        $query->set( 'pa_brands', sanitize_text_field( $_GET['product-brand'] ) );
    }
    
  2. In your template, replace the $products->have_posts() with have_posts() and then the $products->the_post() with the_post(), i.e. just use the main loop to display the posts.

That way, you would not need to modify your pagination function and simply use echo wordpress_numeric_post_nav(); to display the pagination.

Additional Notes

  1. On singular pages like a Page (post type page), your secondary WP_Query query and loop, as well as the pagination, would work properly. (Because the main query does not use paged)

  2. WordPress core has built-in pagination functions like the_posts_pagination() and paginate_links().

  3. You can learn more about the main (WordPress) query at https://codex.wordpress.org/Query_Overview.

    Sorry, I could not find an updated version of the above article on the WordPress Developer Resources site..