Custom Search for CPTs and Taxonomies in WordPress
The default WordPress search looks at posts and pages, but real-world sites often need a dedicated search across custom post types (CPTs) with filters by custom taxonomies and even custom fields. This guide shows two robust approaches: (A) enhance the native search using pre_get_posts, and (B) build a dedicated, fully controlled search page using WP_Query. You’ll get copy-paste snippets for the form, query logic, and results.
Approach A: Extend the Native Search (pre_get_posts)
Use WordPress’s built-in search URL (?s=keyword) and add extra params like post_type[]=book or genre[]=12. This is the quickest way to support CPTs and tax filters on /?s=….
1) Search Form (supports multiple CPTs & taxonomy terms)
<form role="search" method="get" action="<?= esc_url( home_url( '/' ) ); ?>">
<label>
<span class="screen-reader-text">Search</span>
<input type="search" name="s" value="<?= esc_attr( get_search_query() ); ?>" placeholder="Keywords…" />
</label>
<!-- CPT checkboxes: search across books + courses -->
<fieldset>
<legend>Content Types</legend>
<label><input type="checkbox" name="post_type[]" value="book" <?php checked( in_array( 'book', (array) ($_GET['post_type'] ?? []), true ) ); ?>> Books</label>
<label><input type="checkbox" name="post_type[]" value="course" <?php checked( in_array( 'course', (array) ($_GET['post_type'] ?? []), true ) ); ?>> Courses</label>
</fieldset>
<!-- Custom taxonomy: genre (multi-select) -->
<fieldset>
<legend>Genres</legend>
<?php
$terms = get_terms( [
'taxonomy' => 'genre',
'hide_empty' => false,
] );
$selected = array_map( 'intval', (array) ($_GET['genre'] ?? []) );
foreach ( $terms as $t ) :
?>
<label>
<input type="checkbox" name="genre[]" value="<?= (int) $t->term_id; ?>" <?php checked( in_array( $t->term_id, $selected, true ) ); ?>>
<?= esc_html( $t->name ); ?>
</label>
<?php endforeach; ?>
</fieldset>
<button type="submit">Search</button>
</form>
2) Modify the main query with pre_get_posts
<?php
// functions.php
add_action( 'pre_get_posts', function( $q ) {
if ( ! $q->is_main_query() || ! $q->is_search() || is_admin() ) return;
// 2.1 Post types (allow list)
$allowed_cpts = [ 'post', 'page', 'book', 'course' ]; // adjust to your site
$pt = array_values( array_intersect(
$allowed_cpts,
array_map( 'sanitize_key', (array) ($_GET['post_type'] ?? []) )
) );
if ( empty( $pt ) ) {
// default to CPTs you want searchable
$pt = [ 'book', 'course' ];
}
$q->set( 'post_type', $pt );
// 2.2 Taxonomy filters (genre)
$genre_ids = array_filter( array_map( 'intval', (array) ($_GET['genre'] ?? []) ) );
if ( $genre_ids ) {
$tax_query = (array) $q->get( 'tax_query' );
$tax_query[] = [
'taxonomy' => 'genre',
'field' => 'term_id',
'terms' => $genre_ids,
'operator' => 'IN', // OR within the same taxonomy by default
];
$q->set( 'tax_query', $tax_query );
}
// 2.3 Sort newest first
$q->set( 'orderby', 'date' );
$q->set( 'order', 'DESC' );
} );
Notes: The example uses “IN” (OR logic within one taxonomy). If you add multiple taxonomy arrays, set $tax_query['relation'] = 'AND' when you need to require all tax filters at once.
Approach B: Dedicated Search Page (full control with WP_Query)
For advanced scenarios (compound AND/OR logic, meta ranges, custom sorting), build a standalone template (e.g., a page “/find/”) that runs its own WP_Query. This avoids side effects on the native search and keeps the logic isolated and testable.
1) Create the page template
<?php
/* Template Name: CPT Finder */
get_header();
?>
<h1>Find Content</h1>
<!-- Reuse the form from Approach A (action can point to this page's permalink) -->
<?php
// Collect inputs safely
$keyword = isset($_GET['s']) ? wp_unslash( $_GET['s'] ) : '';
$posttypes = array_values( array_intersect(
['book','course'], array_map( 'sanitize_key', (array) ($_GET['post_type'] ?? []) )
) );
if ( empty( $posttypes ) ) $posttypes = ['book','course'];
$genres = array_filter( array_map( 'intval', (array) ($_GET['genre'] ?? []) ) );
// Build tax_query with AND across different taxonomies (example)
$tax_query = [];
if ( $genres ) {
$tax_query[] = [
'taxonomy' => 'genre',
'field' => 'term_id',
'terms' => $genres,
'operator' => 'IN', // OR within genre
];
}
if ( count( $tax_query ) > 1 ) {
$tax_query = array_merge( ['relation' => 'AND'], $tax_query );
}
// Optional: meta range filter (e.g., difficulty or price)
$meta_query = [];
if ( isset($_GET['level']) && in_array( $_GET['level'], ['beginner','intermediate','advanced'], true ) ) {
$meta_query[] = [
'key' => 'difficulty',
'value' => sanitize_text_field($_GET['level']),
'compare' => '=',
];
}
// Example price range
$min_price = isset($_GET['min_price']) ? (float) $_GET['min_price'] : null;
$max_price = isset($_GET['max_price']) ? (float) $_GET['max_price'] : null;
if ( null !== $min_price || null !== $max_price ) {
$range = [ 'key' => 'price', 'type' => 'NUMERIC' ];
if ( null !== $min_price && null !== $max_price ) {
$range['value'] = [ $min_price, $max_price ];
$range['compare'] = 'BETWEEN';
} elseif ( null !== $min_price ) {
$range['value'] = $min_price;
$range['compare'] = '>=';
} else {
$range['value'] = $max_price;
$range['compare'] = '<=';
}
$meta_query[] = $range;
}
if ( count($meta_query) > 1 ) {
$meta_query = array_merge( ['relation' => 'AND'], $meta_query );
}
// Pagination
$paged = max( 1, (int) get_query_var('paged') );
// Final query args
$args = [
'post_type' => $posttypes,
's' => $keyword,
'tax_query' => $tax_query ?: [],
'meta_query' => $meta_query ?: [],
'posts_per_page' => 10,
'paged' => $paged,
'orderby' => 'date',
'order' => 'DESC',
'no_found_rows' => false, // keep true only if you do NOT need pagination
];
$q = new WP_Query( $args );
?>
<h2>Search Results</h2>
<?php if ( $q->have_posts() ) : while ( $q->have_posts() ) : $q->the_post(); ?>
<article>
<h3><a href="<?= esc_url( get_permalink() ); ?>"><?= esc_html( get_the_title() ); ?></a></h3>
<p><?= esc_html( wp_strip_all_tags( get_the_excerpt() ) ); ?></p>
</article>
<?php endwhile; ?>
<?= paginate_links( [
'total' => (int) $q->max_num_pages,
'current' => $paged,
] ); ?>
<?php else : ?>
<p>No results. Try different filters.</p>
<?php endif; wp_reset_postdata(); ?>
<?php get_footer(); ?>
AND vs OR: How to Control Filter Logic
- Within a single taxonomy (e.g., multiple genres): use
'operator' => 'IN'for OR, or'AND'for must-match all selected terms (rare). - Across multiple taxonomies (genre + topic): wrap your arrays with
['relation' => 'AND']to require both taxonomies. - Meta queries: combine multiple constraints with
['relation' => 'AND']or'OR'depending on your use case.
Security & Sanitization Checklist
- Sanitize
post_typewithsanitize_key()and intersect with an allow list. - Sanitize term IDs with
intvaland verify taxonomy names are expected. - Sanitize text inputs with
sanitize_text_field(); URLs withesc_url(). - Escape output in templates:
esc_html(),esc_attr(),esc_url().
Performance Tips (big catalogs)
- Indexes: Ensure
wp_postmetahas an index on(post_id, meta_key)or use plugins that assist indexing. - Limit fields: For intermediate merges, use
'fields' => 'ids'to get only IDs. - Caching: Cache expensive combined ID lists with transients or object cache.
- no_found_rows: Set to
truewhen you don’t need pagination to avoid COUNT queries.
Optional: Show Active Filters Above Results
<p>
<strong>Keyword:</strong> <?= esc_html( $keyword ?: '—' ); ?><br>
<strong>Types:</strong> <?= $posttypes ? esc_html( implode( ', ', $posttypes ) ) : '—'; ?><br>
<strong>Genres:</strong>
<?php
if ( $genres ) {
$names = array_map( fn($id) => get_term( $id, 'genre' )->name ?? '', $genres );
echo esc_html( implode( ', ', array_filter( $names ) ) );
} else {
echo '—';
}
?>
</p>
Conclusion
For simple enhancements, hook into pre_get_posts and feed WordPress extra params for CPTs and taxonomies. When you need strict AND/OR logic, ranges, or custom sorting, create a dedicated finder page with your own WP_Query. With careful sanitization and a lean query plan, you’ll deliver fast, precise search across complex content models.
🎨 Want to learn more? Visit our WordPress Customization Hub for tips and advanced techniques.