Building An Advanced WordPress Search With WP_Query
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.
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 Link
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:
- WordPress parses the requested URL into a set of query parameters (called query specification).
- All the
is_
variables related to the query are set. - The query specification is converted into a MySQL query, which is executed against the database.
- The retrieved dataset is stored in the
$wp_query
object. - WordPress then handles 404 errors.
- WordPress sends blog HTTP headers.
- The Loop variables are initialized.
- The template file is selected according to the template hierarchy rules.
- WordPress runs the Loop.
URL parsing comes first, so let’s dive into query strings and variables.
WP QUERY VARS: DEFAULTS AND CUSTOM VARIABLES LINK
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 likecategory__in
, category__not_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 LINK
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 theget_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
LINK
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 haveAuthor 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 itsmethods 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 runwp_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 LINK
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 LINK
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 eachaccommodation
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 LINK
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 fieldscity
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 LINK
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 thewebdesign
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 Link
We will follow these steps:
- Define the data structure.
- Register the custom query vars.
- Get the query var values and use them to build a custom query.
- Build a form programmatically generating the field values.
1. DEFINE THE DATA STRUCTURE LINK
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.
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 LINK
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 LINK
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
andWP_Meta_Query
source code)
4. BUILD THE SEARCH FORM LINK
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 second form field is still a select button, and it corresponds to thetypology
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 thetypology
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.
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 thearchive.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.
A FREE TEXT SEARCH LINK
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 theget_search_query()
returning value (read the Codex for more details).
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.
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.
[Source:- Smashingmagazine]