About The Author

Carlo is a freelance front-end designer and developer. In the last years he’s been writing for ioProgrammo, an Italian printed computer magazine, and …

More about Carlo

Building An Advanced WordPress Search With WP_Query

Quick Summary

Many WordPress superpowers come from its flexible data architecture that allows developers to widely customize their installations with custom post types, taxonomies and fields. However, when it comes down to its search, WordPress provides us with only one-field form that often appears inadequate and leads site admins to adopt external search systems, like Google Custom Search, or third-party plugins.

Table of Contents
Membership counter

Members support Smashing

Wonderful, friendly people who keep this lil' site alive — and get smarter every day.

Are you smashing, too? →

Many WordPress superpowers come from its flexible data architecture that allows developers to widely customize their installations with custom post types, taxonomies and fields. However, when it comes down to its search, WordPress provides us with only one-field form that often appears inadequate and leads site admins to adopt external search systems, like Google Custom Search, or third-party plugins.

In this article I’ll show you how to provide your WordPress installation with an advanced search system allowing the user to search and retrieve content from a specific custom post type, filtering results by custom taxonomy terms and multiple custom field values.

Further Reading on SmashingMag:

The article has two parts. First, I will present a theoretical introduction to handling user requests, starting from the URL transmission, passing through the query execution, and ending with the output production. The second part of the article is a concrete application of what we’re going to learn in the first part, and there we will build our advanced search system.

So, let’s start learning some key concepts.

User Requests

When a user clicks on a link or types a URL pointing to a page of the website, WordPress performs a series of operations described well in the Codex Query Overview. Briefly, this is what happens:

  1. WordPress parses the requested URL into a set of query parameters (called query specification).
  2. All the is_ variables related to the query are set.
  3. The query specification is converted into a MySQL query, which is executed against the database.
  4. The retrieved dataset is stored in the $wp_query object.
  5. WordPress then handles 404 errors.
  6. WordPress sends blog HTTP headers.
  7. The Loop variables are initialized.
  8. The template file is selected according to the template hierarchy rules.
  9. WordPress runs the Loop.

URL parsing comes first, so let’s dive into query strings and variables.

WP Query Vars: Defaults And Custom Variables

The Codex states:

An array of query vars are available for WordPress users or developers to utilise in order to query for particular types of content or to aid in theme and/or plugin design and functionality.

In other words, WordPress query vars are those variables in a query string that determine (or affect) results in a query performed against the database. By default, WordPress provides public and private query vars, and the Codex defines them as follows:

Public query vars are those available and usable via a direct URL query in the form of example.net/?var1=value1&var2=value2. Private query vars cannot be used in the URL, although WordPress will accept a query string with private query vars, the values will not be passed into the query and should be placed directly into the query. An example is given below.

query_posts('privatevar=myvalue');

As a consequence, it’s not possible to send via a query string private vars like categoryin, categorynot_in, category__end, etc. (check the Codex for a comprehensive list of the built-in query vars) .

With the public variables at our disposal (as users as well as developers), we can assemble a great number of queries with no need to develop a plugin or edit the theme’s functions file. We just need to build a URL, add to the query string one or more of the available parameters, and WordPress will show the requested results to the user.

As an example, we can query for a specific post type by adding the post_type parameter to the query string; or we can request a custom taxonomy, appending to the query string the pair taxonomy-name=taxonomy-term. For instance, we can build the following URL:

mywebsite.com/?post_type=movie&genre=thriller

WordPress will query the database and retrieve all movie post types belonging to the thriller genre, where genre is a custom taxonomy.

It’s awesome, but that’s not all. What we have said so far, in fact, concerns just the built-in functionalities of query vars. WordPress allows us to go further and create our own custom query variables.

Register Custom Query Vars

Before we can use them, the custom query vars should be registered. We can accomplish this task thanks to the query_vars filter. So, let’s open the main file of a plugin (or a theme’s functions.php file) and write the following code:

/**
 * Register custom query vars
 *
 * @link https://codex.wordpress.org/Plugin_API/Filter_Reference/query_vars
 */
function myplugin_register_query_vars( $vars ) {
    $vars[] = 'author';
    $vars[] = 'editor';
    return $vars;
}
add_filter( 'query_vars', 'myplugin_register_query_vars' );

The callback function keeps an array of variables as an argument, and must return the same array when new variables have been added

Now we can include the new variables in the parameters that will affect the query. These parameters will be available in our scripts thanks to the get_query_var() template tag, as we’ll see later. Now it’s time to introduce the class that manages the queries in WordPress.

Her Majesty The WP_Query

Querying a database is not an easy task. It’s not only a matter of building an efficient query, but it’s a problem that requires security issues to be carefully taken into account. Thanks to WP_Query Class, WordPress gives us access to the database quickly (no need to get our hands dirty with SQL) and securely (WP_Query builds safe queries behind the scenes).

The retrieved dataset will be available for use in the Loop, thanks to the many methods and properties of the class.

Let’s take a generic Loop as an example:

<?php if ( have_posts() ) : while ( have_posts() ) : the_post(); ?>
    <!-- code here -->
<?php endwhile; else : ?>
    <!-- code here -->
<?php endif; ?>

If you are new to WordPress development, you may ask: “Hey, buddy! Where’s the Query?”

In fact, you don’t need to create a new instance of the WP_Query object. The class itself establishes the query to be executed according to the requested page. So, if the site viewer requires a category archive, WordPress will run a query retrieving all posts belonging to that specific category, and the Loop will show them.

But this is just a vary basic example of a main query. We can do a lot of more, and filter the returning result set granularly, just by passing an array of parameters to a new instance of the WP_Query class, as we’ll do in the following example:

// An array of arguments
$args = array( 'arg_1' => 'val_1', 'arg_2' => 'val_2' );

// The Query
$the_query = new WP_Query( $args );

// The Loop
if ( $the_query->have_posts() ) {

    while ( $the_query->have_posts() ) : $the_query->the_post(); 
        // Your code here
    endwhile;

} else {
        // no posts found
}
/* Restore original Post Data */
wp_reset_postdata();

Things look a bit more complicated, don’t they? But if we look closely, they’re not.

The new instance of WP_Query keeps an array of arguments that will affect data retrieved from the database. The Codex provides the full list of parameters, grouping them in seventeen categories. So, for instance, we have Author Params, Category Params, just one Search parameter (s), Custom Field params, and so on (we’ll get back to WP_Query params in a moment).

Now that we have instantiated the $query object, we can access all its methods and properties. have_posts checks whether any post remains to be printed, while the_post moves the Loop forward to the succeeding post and updates the $post global variable.

Outside the Loop, when using a custom query, we should always make a call to wp_reset_postdata(). This function restores the $post global variable after the execution of a custom query, and is necessary because any new query overwrites $post. From the Codex:

Note: If you use the_post() with your query, you need to run wp_reset_postdata() afterwards to have Template Tags use the main query’s current post again.

Now let’s get back to the query args.

WP_Query Arguments

We said that the WP_Query class keeps an array of parameters that allow developers to select granularly the results from the database.

The first group, the Author Parameters, includes those arguments that allow us to build queries based on the author(s) of the posts (pages and post types). They include:

  • author
  • author_name
  • author__in
  • author__not_in

If you want to retrieve all the posts from carlo, you just need to set the following query:

$query = new WP_Query( array( 'author_name' => 'carlo' ) );

The second group includes the Category Parameters, i.e. all those arguments that allow us to query for (or exclude) posts assigned to one or more categories:

  • cat
  • category_name
  • category__and
  • category__in
  • category__not_in

If we needed all posts assigned to the webdesign category, we just have to set the category_name argument, as we’ll do in the following example:

$query = new WP_Query( array( 'category_name' => 'webdesign' ) );

The following query searches for posts from more than one category, the comma standing in for OR:

$query = new WP_Query( array( 'category_name' => 'webdesign,webdev' ) );

We can also ask for posts belonging to both webdesign and webdev categories, with plus (+) meaning AND:

$query = new WP_Query( array( 'category_name' => 'webdesign+webdev' ) );

And we can also pass an array of IDs, as in the next examples:

$query = new WP_Query( array( 'category__in' => array( 4, 9 ) ) );
$query = new WP_Query( array( 'category__and' => array( 4, 9 ) ) );

And so on with tags and taxonomies, search keywords, posts, pages and post types. Refer to the Codex for a more detailed walk-through of query arguments.

As we said before, we can also set more than one argument and retrieve, for instance, all posts in a specific category AND written by a certain author:

$query = new WP_Query( array( 'author_name' => 'carlo', 'category_name' => 'webdesign' ) );

When the data architecture get more complex – and that occurs when we add custom fields and taxonomies to post types – then it could become necessary to set one or more Custom Field parameters allowing us to retrieve all posts (or custom post types) labeled with specific custom field values. Shortly, we’ll need to execute a meta query against the database.

WordPress Meta Queries

The Codex informs us that when dealing with a meta query, WP_Query uses the WP_Meta_Query class. This class, introduced in WordPress 3.2, builds the SQL code of the queries based on custom fields.

To build a query based on a single custom field, we just need one or more of the following arguments:

  • meta_key
  • meta_value
  • meta_value_num
  • meta_compare

Suppose a custom post type is named accommodation. Let’s assign to each accommodation a custom field named city, which stores the name of a geographical location. With a meta query we can retrieve from the database all accommodations located in the specified city, simply passing the right arguments to the query, as you can see below:

$args = array( 
    'post_type'     => 'accommodation', 
    'meta_key'      => 'city', 
    'meta_value'        => 'Freiburg', 
    'meta_compare'  => 'LIKE' );

Once we’ve set the arguments, we can build the query the same way as before:

// The Query
$the_query = new WP_Query( $args );

// The Loop
if ( $the_query->have_posts() ) {

    while ( $the_query->have_posts() ) : $the_query->the_post(); 
        // Your code here
    endwhile;

} else {
        // no posts found
}
/* Restore original Post Data */
wp_reset_postdata();

Copy and paste the code above in a template file and you’ll get an archive of all accommodations available in Freiburg.

This is the case for a single custom field. But what if we needed to build a query based on multiple custom fields?

The meta_query Argument

For this kind of query, the WP_Meta_Query class (and the WP_Query class as well) provides the meta_query parameter. This has to be an array of arrays, as shown in the following example:

$args = array(
    'post_type'  => 'accommodation',
    'meta_query' => array(
        array(
            'key'     => 'city',
            'value'   => 'Freiburg',
            'compare' => 'LIKE',
        ),
    )
);

The meta_query element is a bidimensional array whose items are single meta queries with the following arguments:


Argument Type Description
key string Identifies the custom field.
value string|array Can be an array just when the compare value is ‘IN’, ‘NOT IN’, ‘BETWEEN’, or ‘NOT BEETWEEN’.
compare string A comparison operator. Accepted values are ’=’, ’!=’, ‘>‘, ‘>=’, ‘<‘, ‘<=’, ‘LIKE’, ‘NOT LIKE’, ‘IN’, ‘NOT IN’, ‘BETWEEN’, ‘NOT BETWEEN’, ‘EXISTS’, ‘NOT EXISTS’, ‘REGEXP’, ‘NOT REGEXP’ and ‘RLIKE’. It defaults to ’=’.
type string The custom field type. Possible values are ‘NUMERIC’, ‘BINARY’, ‘CHAR’, ‘DATE’, ‘DATETIME’, ‘DECIMAL’, ‘SIGNED’, ‘TIME’, ‘UNSIGNED’. It defaults to ‘CHAR’.

If we set more than one custom field, we also have to assign the relation argument to the meta_query element.

Now we can build a more advanced query. Let’s begin by setting the arguments and creating a new instance of WP_Query:

$args = array(
    'post_type' => 'accommodation',
    'meta_query'    => array(
        array( 'key' => 'city', 'value' => 'Paris', 'compare' => 'LIKE' ),
        array( 'key' => 'type', 'value' => 'room', 'compare' => 'LIKE' ),
        'relation' => 'AND'
    )
);
$the_query = new WP_Query( $args );

Here, the meta_query argument holds two meta query arrays and a third parameter setting the relation between the meta queries. The query searches the wp_posts table for all accommodation post types where the custom fields city and type store respectively the values Paris and room.

Let’s copy and paste the code into a template file named archive-accommodation.php. When requested, WordPress will execute the query searching the wp_posts table, and the Loop will show the results, if available.

At this point we’re still writing the code in a template file. This means that our script is static and each time the Loop runs, it will produce the same output. But we need to allow site users to make custom requests, and to accomplish this task we need to dynamically build custom queries.

The pre_get_posts Filter

The pre_get_posts action hook is fired after the $query object creation, but before its execution. To modify the query, we’ll have to hook a custom callback to pre_get_posts.

In an earlier example, we queried the database to retrieve all posts in the webdesign category from a certain author. In the following example we’re passing the same arguments to the $query object, but we won’t do the job with a template file, as we’ve done before, but we’ll use the main file of a plugin (or a theme’s functions.php), instead. Let’s write the following block of code:

function myplugin_pre_get_posts( $query ) {
    // check if the user is requesting an admin page 
    // or current query is not the main query
    if ( is_admin() || ! $query->is_main_query() ){
        return;
    }
    $query->set( 'author_name', 'carlo' );
    $query->set( 'category_name', 'webdesign' );
}
add_action( 'pre_get_posts', 'myplugin_pre_get_posts', 1 );

The $query object is passed to the callback function by reference, not by value, and this means that any changes made to the query directly affect the original $query object. The Codex says:

The pre_get_posts action gives developers access to the $query object by reference (any changes you make to $query are made directly to the original object - no return value is necessary).

As we’re manipulating the original $query object, we have to pay attention to which query we’re working on. The is_main_query method checks if the current $query object is… (yes!) the main query. The Codex also informs us that the pre_get_posts filter can affect the admin panel as well as the front-end pages. For this reason, it’s more than appropriate to check the requested page with the is_admin conditional tag as well.

The pre_get_posts action hook is well documented and it’s well worth reading the Codex for a more detailed description and several examples of use.

We can now end our introduction to the the main tools available to handle WordPress queries. Now it’s time to present a concrete example of their use, building an advanced search system of the site content. Our case study is provided by a real estate website.

From Theory To Code: Building The Search System

We will follow these steps:

  1. Define the data structure.
  2. Register the custom query vars.
  3. Get the query var values and use them to build a custom query.
  4. Build a form programmatically generating the field values.

1. Define The Data Structure

The purpose of the custom post types is to add contents that logically can be included neither in blog posts nor in static pages. Custom post types are particularly appropriate to present events, products, books, movies, catalogue items, and so on. Here we’re going to build an archive of real estate ads with the following structure:

  • custom post type: accommodation;
  • custom taxonomy: typology (B&B, homestay, hotel, etc.)
  • custom field: _sm_accommodation_type (entire house, private room, shared room)
  • custom field: _sm_accommodation_city
  • other custom fields

We have to register the post type, the custom taxonomy and custom fields and meta boxes, as shown in the figure below.

Accommodation edit page
The edit accommodation page shows all custom meta boxes and fields. (View large version)

The image shows how the Edit Accommodation page will appear once three custom meta boxes containing the Typology custom taxonomy and several custom fields have been registered.

It’s not our goal to analyze WordPress data architecture, as this topic has already been covered here on Smashing Magazine by Daniel, Brian, Kevin, Josh and Justin. Read their articles if you need a refresher, and come back as soon as you’re ready.

Once we’ve defined the data architecture, it’s time to register the query vars.

2. Register The Query Variables

Earlier we defined query vars as key=value pairs following a question mark in a URL. But before we can handle these pairs in our scripts, we have to register them in a plugin or functions file. For our purposes, we need just two variables that will enable the execution of a query based on the values of the corresponding custom fields:

/**
 * Register custom query vars
 *
 * @link https://codex.wordpress.org/Plugin_API/Filter_Reference/query_vars
 */
function sm_register_query_vars( $vars ) {
    $vars[] = 'type';
    $vars[] = 'city';
    return $vars;
} 
add_filter( 'query_vars', 'sm_register_query_vars' );

That’s it! We have added two more parameters to query the database. Now it would make sense to build a URL like this one:

http://example.com/?type=XXX&city=YYY

3. Manipulate The Query

Now let’s add a new block of code to our script:

/**
 * Build a custom query based on several conditions
 * The pre_get_posts action gives developers access to the $query object by reference
 * any changes you make to $query are made directly to the original object - no return value is requested
 *
 * @link https://codex.wordpress.org/Plugin_API/Action_Reference/pre_get_posts
 *
 */
function sm_pre_get_posts( $query ) {
    // check if the user is requesting an admin page 
    // or current query is not the main query
    if ( is_admin() || ! $query->is_main_query() ){
        return;
    }

    // edit the query only when post type is 'accommodation'
    // if it isn't, return
    if ( !is_post_type_archive( 'accommodation' ) ){
        return;
    }

    $meta_query = array();

    // add meta_query elements
    if( !empty( get_query_var( 'city' ) ) ){
        $meta_query[] = array( 'key' => '_sm_accommodation_city', 'value' => get_query_var( 'city' ), 'compare' => 'LIKE' );
    }

    if( !empty( get_query_var( 'type' ) ) ){
        $meta_query[] = array( 'key' => '_sm_accommodation_type', 'value' => get_query_var( 'type' ), 'compare' => 'LIKE' );
    }

    if( count( $meta_query ) > 1 ){
        $meta_query['relation'] = 'AND';
    }

    if( count( $meta_query ) > 0 ){
        $query->set( 'meta_query', $meta_query );
    }
}
add_action( 'pre_get_posts', 'sm_pre_get_posts', 1 );

This code is the sum of all we covered in the first part of the article. The callback function quits if the user is in the admin panel, if the current query is not the main query, and if the post type is not accommodation.

Later, the function checks if any of the query vars we’ve registered before are available. This task is accomplished thanks to the get_query_var function, which retrieves a public query variable from an HTTP request (read more about get_query_var in the Codex). If the variable exists, then the callback defines one array for each meta query and pushes it into the multi-dimensional $meta_query array.

Finally, if at least two meta queries are available, then the relation argument is pushed into $meta_query and its value is set to ‘AND’. Once done, the set method saves $query for its subsequent execution.

You don’t need to worry about data sanitization here, because the WP_Query and WP_Meta_Query classes do the job for us (check the WP_Query and WP_Meta_Query source code)

4. Build The Search Form

The form data are submitted with the GET method. This means that the name and value attributes of the form fields are sent as URL variables (i.e. as query vars). So we’re going to give the name attributes of the form fields the same values as the previously registered query vars (city and type), while the field values could be assigned programmatically, retrieving data from the database, or filled in by the user.

First we will create a shortcode that will allow the site admin to include a search form in posts and pages of the website. Our shortcode will be hooked to the init action:

function sm_setup() {
    add_shortcode( 'sm_search_form', 'sm_search_form' );
}
add_action( 'init', 'sm_setup' );

Next we will define the callback function that will produce the HTML of a form containing three select fields, corresponding to a custom taxonomy and two custom fields.

function sm_search_form( $args ){
    // our code here
}

$args is an array of the shortcode attributes. Inside the function we’ll add the following code:

// The Query
// meta_query expects nested arrays even if you only have one query
$sm_query = new WP_Query( array( 'post_type' => 'accommodation', 'posts_per_page' => '-1', 'meta_query' => array( array( 'key' => '_sm_accommodation_city' ) ) ) );

// The Loop
if ( $sm_query->have_posts() ) {
    $cities = array();
    while ( $sm_query->have_posts() ) {
        $sm_query->the_post();
        $city = get_post_meta( get_the_ID(), '_sm_accommodation_city', true );

        // populate an array of all occurrences (non duplicated)
        if( !in_array( $city, $cities ) ){
            $cities[] = $city;    
        }
    }
}
} else{
       echo 'No accommodations yet!';
       return;
}

/* Restore original Post Data */
wp_reset_postdata();

if( count($cities) == 0){
    return;
}

asort($cities);

$select_city = '<select name="city" style="width: 100%">';
$select_city .= '<option value="" selected="selected">' . __( 'Select city', 'smashing_plugin' ) . '</option>';
foreach ($cities as $city ) {
    $select_city .= '<option value="' . $city . '">' . $city . '</option>';
}
$select_city .= '</select>' . "\n";

reset($cities);

We’ve built a query that will retrieve all accommodation post types having set a custom field named _sm_accommodation_city (the underscore preceding the name represents a hidden custom field).

The Loop won’t show any accommodation, but will add elements to the $cities array from the corresponding custom field value. The condition will skip duplicates. If no accommodations are available, the execution is interrupted; otherwise the array elements are sorted and used to print the values of the first group of option elements.

The Codex informs us that when dealing with a meta query, WP_Query uses the WP_Meta_Query class. This class, introduced in WordPress 3.2, builds the SQL code of the queries based on custom fields.

To build a query based on a single custom field, we just need one or more of the following arguments:

  • meta_key
  • meta_value
  • meta_value_num
  • meta_compare

Suppose a custom post type is named accommodation. Let’s assign to each accommodation a custom field named city, which stores the name of a geographical location. With a meta query we can retrieve from the database all accommodations located in the specified city, simply passing the right arguments to the query, as you can see below:

$args = array( 
    'post_type'     => 'accommodation', 
    'meta_key'      => 'city', 
    'meta_value'        => 'Freiburg', 
    'meta_compare'  => 'LIKE' );

Once we’ve set the arguments, we can build the query the same way as before:

// The Query
$the_query = new WP_Query( $args );

// The Loop
if ( $the_query->have_posts() ) {

    while ( $the_query->have_posts() ) : $the_query->the_post(); 
        // Your code here
    endwhile;

} else {
        // no posts found
}
/* Restore original Post Data */
wp_reset_postdata();

Copy and paste the code above in a template file and you’ll get an archive of all accommodations available in Freiburg.

This is the case for a single custom field. But what if we needed to build a query based on multiple custom fields?

The meta_query Argument

For this kind of query, the WP_Meta_Query class (and the WP_Query class as well) provides the meta_query parameter. This has to be an array of arrays, as shown in the following example:

$args = array(
    'post_type'  => 'accommodation',
    'meta_query' => array(
        array(
            'key'     => 'city',
            'value'   => 'Freiburg',
            'compare' => 'LIKE',
        ),
    )
);

The meta_query element is a bidimensional array whose items are single meta queries with the following arguments:


Argument Type Description
key string Identifies the custom field.
value string|array Can be an array just when the compare value is ‘IN’, ‘NOT IN’, ‘BETWEEN’, or ‘NOT BEETWEEN’.
compare string A comparison operator. Accepted values are ’=’, ’!=’, ‘>‘, ‘>=’, ‘<‘, ‘<=’, ‘LIKE’, ‘NOT LIKE’, ‘IN’, ‘NOT IN’, ‘BETWEEN’, ‘NOT BETWEEN’, ‘EXISTS’, ‘NOT EXISTS’, ‘REGEXP’, ‘NOT REGEXP’ and ‘RLIKE’. It defaults to ’=’.
type string The custom field type. Possible values are ‘NUMERIC’, ‘BINARY’, ‘CHAR’, ‘DATE’, ‘DATETIME’, ‘DECIMAL’, ‘SIGNED’, ‘TIME’, ‘UNSIGNED’. It defaults to ‘CHAR’.

If we set more than one custom field, we also have to assign the relation argument to the meta_query element.

Now we can build a more advanced query. Let’s begin by setting the arguments and creating a new instance of WP_Query:

$args = array(
    'post_type' => 'accommodation',
    'meta_query'    => array(
        array( 'key' => 'city', 'value' => 'Paris', 'compare' => 'LIKE' ),
        array( 'key' => 'type', 'value' => 'room', 'compare' => 'LIKE' ),
        'relation' => 'AND'
    )
);
$the_query = new WP_Query( $args );

Here, the meta_query argument holds two meta query arrays and a third parameter setting the relation between the meta queries. The query searches the wp_posts table for all accommodation post types where the custom fields city and type store respectively the values Paris and room.

Let’s copy and paste the code into a template file named archive-accommodation.php. When requested, WordPress will execute the query searching the wp_posts table, and the Loop will show the results, if available.

At this point we’re still writing the code in a template file. This means that our script is static and each time the Loop runs, it will produce the same output. But we need to allow site users to make custom requests, and to accomplish this task we need to dynamically build custom queries.

The pre_get_posts Filter

The pre_get_posts action hook is fired after the $query object creation, but before its execution. To modify the query, we’ll have to hook a custom callback to pre_get_posts.

In an earlier example, we queried the database to retrieve all posts in the webdesign category from a certain author. In the following example we’re passing the same arguments to the $query object, but we won’t do the job with a template file, as we’ve done before, but we’ll use the main file of a plugin (or a theme’s functions.php), instead. Let’s write the following block of code:

function myplugin_pre_get_posts( $query ) {
    // check if the user is requesting an admin page 
    // or current query is not the main query
    if ( is_admin() || ! $query->is_main_query() ){
        return;
    }
    $query->set( 'author_name', 'carlo' );
    $query->set( 'category_name', 'webdesign' );
}
add_action( 'pre_get_posts', 'myplugin_pre_get_posts', 1 );

The $query object is passed to the callback function by reference, not by value, and this means that any changes made to the query directly affect the original $query object. The Codex says:

The pre_get_posts action gives developers access to the $query object by reference (any changes you make to $query are made directly to the original object - no return value is necessary).

As we’re manipulating the original $query object, we have to pay attention to which query we’re working on. The is_main_query method checks if the current $query object is… (yes!) the main query. The Codex also informs us that the pre_get_posts filter can affect the admin panel as well as the front-end pages. For this reason, it’s more than appropriate to check the requested page with the is_admin conditional tag as well.

The pre_get_posts action hook is well documented and it’s well worth reading the Codex for a more detailed description and several examples of use.

We can now end our introduction to the the main tools available to handle WordPress queries. Now it’s time to present a concrete example of their use, building an advanced search system of the site content. Our case study is provided by a real estate website.

From Theory To Code: Building The Search System

We will follow these steps:

  1. Define the data structure.
  2. Register the custom query vars.
  3. Get the query var values and use them to build a custom query.
  4. Build a form programmatically generating the field values.

1. Define The Data Structure

The purpose of the custom post types is to add contents that logically can be included neither in blog posts nor in static pages. Custom post types are particularly appropriate to present events, products, books, movies, catalogue items, and so on. Here we’re going to build an archive of real estate ads with the following structure:

  • custom post type: accommodation;
  • custom taxonomy: typology (B&B, homestay, hotel, etc.)
  • custom field: _sm_accommodation_type (entire house, private room, shared room)
  • custom field: _sm_accommodation_city
  • other custom fields

We have to register the post type, the custom taxonomy and custom fields and meta boxes, as shown in the figure below.

Accommodation edit page
The edit accommodation page shows all custom meta boxes and fields. (View large version)

The image shows how the Edit Accommodation page will appear once three custom meta boxes containing the Typology custom taxonomy and several custom fields have been registered.

It’s not our goal to analyze WordPress data architecture, as this topic has already been covered here on Smashing Magazine by Daniel, Brian, Kevin, Josh and Justin. Read their articles if you need a refresher, and come back as soon as you’re ready.

Once we’ve defined the data architecture, it’s time to register the query vars.

2. Register The Query Variables

Earlier we defined query vars as key=value pairs following a question mark in a URL. But before we can handle these pairs in our scripts, we have to register them in a plugin or functions file. For our purposes, we need just two variables that will enable the execution of a query based on the values of the corresponding custom fields:

/**
 * Register custom query vars
 *
 * @link https://codex.wordpress.org/Plugin_API/Filter_Reference/query_vars
 */
function sm_register_query_vars( $vars ) {
    $vars[] = 'type';
    $vars[] = 'city';
    return $vars;
} 
add_filter( 'query_vars', 'sm_register_query_vars' );

That’s it! We have added two more parameters to query the database. Now it would make sense to build a URL like this one:

http://example.com/?type=XXX&city=YYY

3. Manipulate The Query

Now let’s add a new block of code to our script:

/**
 * Build a custom query based on several conditions
 * The pre_get_posts action gives developers access to the $query object by reference
 * any changes you make to $query are made directly to the original object - no return value is requested
 *
 * @link https://codex.wordpress.org/Plugin_API/Action_Reference/pre_get_posts
 *
 */
function sm_pre_get_posts( $query ) {
    // check if the user is requesting an admin page 
    // or current query is not the main query
    if ( is_admin() || ! $query->is_main_query() ){
        return;
    }

    // edit the query only when post type is 'accommodation'
    // if it isn't, return
    if ( !is_post_type_archive( 'accommodation' ) ){
        return;
    }

    $meta_query = array();

    // add meta_query elements
    if( !empty( get_query_var( 'city' ) ) ){
        $meta_query[] = array( 'key' => '_sm_accommodation_city', 'value' => get_query_var( 'city' ), 'compare' => 'LIKE' );
    }

    if( !empty( get_query_var( 'type' ) ) ){
        $meta_query[] = array( 'key' => '_sm_accommodation_type', 'value' => get_query_var( 'type' ), 'compare' => 'LIKE' );
    }

    if( count( $meta_query ) > 1 ){
        $meta_query['relation'] = 'AND';
    }

    if( count( $meta_query ) > 0 ){
        $query->set( 'meta_query', $meta_query );
    }
}
add_action( 'pre_get_posts', 'sm_pre_get_posts', 1 );

This code is the sum of all we covered in the first part of the article. The callback function quits if the user is in the admin panel, if the current query is not the main query, and if the post type is not accommodation.

Later, the function checks if any of the query vars we’ve registered before are available. This task is accomplished thanks to the get_query_var function, which retrieves a public query variable from an HTTP request (read more about get_query_var in the Codex). If the variable exists, then the callback defines one array for each meta query and pushes it into the multi-dimensional $meta_query array.

Finally, if at least two meta queries are available, then the relation argument is pushed into $meta_query and its value is set to ‘AND’. Once done, the set method saves $query for its subsequent execution.

You don’t need to worry about data sanitization here, because the WP_Query and WP_Meta_Query classes do the job for us (check the WP_Query and WP_Meta_Query source code)

4. Build The Search Form

The form data are submitted with the GET method. This means that the name and value attributes of the form fields are sent as URL variables (i.e. as query vars). So we’re going to give the name attributes of the form fields the same values as the previously registered query vars (city and type), while the field values could be assigned programmatically, retrieving data from the database, or filled in by the user.

First we will create a shortcode that will allow the site admin to include a search form in posts and pages of the website. Our shortcode will be hooked to the init action:

function sm_setup() {
    add_shortcode( 'sm_search_form', 'sm_search_form' );
}
add_action( 'init', 'sm_setup' );

Next we will define the callback function that will produce the HTML of a form containing three select fields, corresponding to a custom taxonomy and two custom fields.

function sm_search_form( $args ){
    // our code here
}

$args is an array of the shortcode attributes. Inside the function we’ll add the following code:

// The Query
// meta_query expects nested arrays even if you only have one query
$sm_query = new WP_Query( array( 'post_type' => 'accommodation', 'posts_per_page' => '-1', 'meta_query' => array( array( 'key' => '_sm_accommodation_city' ) ) ) );

// The Loop
if ( $sm_query->have_posts() ) {
    $cities = array();
    while ( $sm_query->have_posts() ) {
        $sm_query->the_post();
        $city = get_post_meta( get_the_ID(), '_sm_accommodation_city', true );

        // populate an array of all occurrences (non duplicated)
        if( !in_array( $city, $cities ) ){
            $cities[] = $city;    
        }
    }
}
} else{
       echo 'No accommodations yet!';
       return;
}

/* Restore original Post Data */
wp_reset_postdata();

if( count($cities) == 0){
    return;
}

asort($cities);

$select_city = '<select name="city" style="width: 100%">';
$select_city .= '<option value="" selected="selected">' . __( 'Select city', 'smashing_plugin' ) . '</option>';
foreach ($cities as $city ) {
    $select_city .= '<option value="' . $city . '">' . $city . '</option>';
}
$select_city .= '</select>' . "\n";

reset($cities);

We’ve built a query that will retrieve all accommodation post types having set a custom field named _sm_accommodation_city (the underscore preceding the name represents a hidden custom field).

The Loop won’t show any accommodation, but will add elements to the $cities array from the corresponding custom field value. The condition will skip duplicates. If no accommodations are available, the execution is interrupted; otherwise the array elements are sorted and used to print the values of the first group of option elements.

Select box
All available cities are shown as options in a select box.

The second form field is still a select button, and it corresponds to the typology custom taxonomy. The values of the second group of option elements are provided by the get_terms template tag. Here follows the second block of code, generating a new select field corresponding to the typology taxonomy:

$args = array( 'hide_empty' => false );
$typology_terms = get_terms( 'typology', $args );
if( is_array( $typology_terms ) ){
    $select_typology = '<select name="typology" style="width: 100%">';
    $select_typology .= '<option value="" selected="selected">' . __( 'Select typology', 'smashing_plugin' ) . '</option>';
    foreach ( $typology_terms as $term ) {
        $select_typology .= '<option value="' . $term->slug . '">' . $term->name . '</option>';
    }
    $select_typology .= '</select>' . "\n";
}

get_terms returns an array of all terms of the taxonomy set as first argument, or a WP_Error object if the taxonomy does not exist. Now again a foreach cycle prints out the option elements.

Select box
The options of the second select box.

Then we build the last select element, corresponding to the type custom field. Here is the code:

$select_type = '<select name="type" style="width: 100%">';
$select_type .= '<option value="" selected="selected">' . __( 'Select room type', 'smashing_plugin' ) . '</option>';
$select_type .= '<option value="entire">' . __( 'Entire house', 'smashing_plugin' ) . '</option>';
$select_type .= '<option value="private">' . __( 'Private room', 'smashing_plugin' ) . '</option>';
$select_type .= '<option value="shared">' . __( 'Shared room', 'smashing_plugin' ) . '</option>';
$select_type .= '</select>' . "\n";

As you can see, in this case we have manually set the option values.

Finally, we can print out the form:

$output = '<form action="' . esc_url( home_url() ) . '" method="GET" role="search">';
$output .= '<div class="smselectbox">' . esc_html( $select_city ) . '</div>';
$output .= '<div class="smselectbox">' . esc_html( $select_typology ) . '</div>';
$output .= '<div class="smselectbox">' . esc_html( $select_type ) . '</div>';
$output .= '<input type="hidden" name="post_type" value="accommodation" />';
$output .= '<p><input type="submit" value="Go!" class="button" /></p></form>';

return $output;

We’ve set a hidden input field for the post_type public query var. When the user submits the form, WordPress gets the post_type value, and loads the archive.php template file, or, if available, the archive-{post_type}.php file. With this kind of form, if you’re going to customize the HTML structure of the resulting page, you’ll need to provide the most appropriate template file.

Advanced search form
The image shows the advanced search form we've been building through this article.

The form we’ve built so far allows the user to set up three filters from a number of predetermined options. We’re now going to improve the search system including a text field in the form, so that users can search accommodation by custom keywords. We can do that thanks to the s query argument. So, let’s change the form as follows:

$output = '<form id="smform" action="' . esc_url( home_url() ) . '" method="GET" role="search">';
$output .= '<div class="smtextfield"><input type="text" name="s" placeholder="Search key..." value="' . get_search_query() . '" /></div>';
$output .= '<div class="smselectbox">' . esc_html( $select_city ) . '</div>';
$output .= '<div class="smselectbox">' . esc_html( $select_typology ) . '</div>';
$output .= '<div class="smselectbox">' . esc_html( $select_type ) . '</div>';
$output .= '<input type="hidden" name="post_type" value="accommodation" />';
$output .= '<p><input type="submit" value="Go!" class="button" /></p></form>';

Thanks to the text field, we can pass WP_Query a new key/value pair, where the key is the s parameter, and the value is the user input or the get_search_query() returning value (read the Codex for more details).

Advanced search form
A more advanced search form.

A final note: in our preceding example, we’ve seen WordPress loading an archive template file to show the results of the query. That’s because we did not set the search argument. When the query string contains the s param, WordPress automatically loads the search template file, as shown in the last image of this post.

Advanced search form
The image shows the search page in Twenty Fifteen.

Conclusions

The examples of this article are intended to demonstrate what can be achieved with the tools provided by WordPress. Sure, the form can be improved by adding new fields allowing more granular customization. Nevertheless, I hope I’ve provided a quite detailed view of the functionalities that make it possible to build a search system overcoming the limits of the built-in search system with no need of external services and plugins.

Now it’s time to code!

Resources

Smashing Editorial (og)