Custom Search for CPTs and Taxonomies in WordPress

October 27, 2025
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_type with sanitize_key() and intersect with an allow list.
  • Sanitize term IDs with intval and verify taxonomy names are expected.
  • Sanitize text inputs with sanitize_text_field(); URLs with esc_url().
  • Escape output in templates: esc_html(), esc_attr(), esc_url().

Performance Tips (big catalogs)

  • Indexes: Ensure wp_postmeta has 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 true when 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.

Avatar

Written by

satoshi

I’ve been building and customizing WordPress themes for over 10 years. In my free time, you’ll probably find me enjoying a good football match.