
Sur WordPress, filtrer un catalogue par taxonomie est relativement simple : beaucoup de plugins gratuits proposent déjà des filtres par catégories ou par tags.
Le problème apparaît dès que l'on veut aller plus loin, par exemple :
À ce stade, on tombe souvent sur des solutions payantes… alors que WordPress fournit déjà tout ce qu'il faut pour construire un moteur de filtres sur mesure.
Imaginez un site de location de logements. Un visiteur cherche une maison (plutôt qu'un appartement), et souhaite disposer du WiFi, d'une piscine et d'un jardin. Le premier critère relève d'une taxonomie hiérarchique (Type de bien > Maison), tandis que les équipements sont des caractéristiques ACF à cocher. Ce type de recherche multi-critères est exactement ce que nous allons construire.
Dans cet article, on va créer un système de filtres AJAX "type facettes" fonctionnel, avec :
Le système repose sur 3 composants qui communiquent entre eux :

Le flux complet :
WP_Query filtréeConcrètement, nous allons créer :
Voici la structure du formulaire à placer dans votre template ou page :
<form id="monFormulaireFiltres" method="POST" action="#">
<input type="hidden" name="action" value="mon_filtre_ajax">
<input type="hidden" name="base_parent_cat" value="9">
<!-- Groupe : Catégories -->
<fieldset>
<legend>Filtrer par catégorie</legend>
[filtre_categories taxonomy="categorie" parent="9"]
</fieldset>
<!-- Groupe : Options ACF -->
<fieldset>
<legend>Filtrer par options</legend>
[filtre_acf_checkbox field="caracteristiques"]
</fieldset>
</form>
<!-- Zone de résultats avec aria-live pour annoncer les mises à jour -->
<div id="zoneResultats" aria-live="polite" aria-busy="false"></div>
Dans notre exemple, on part du principe que l'on veut afficher les sous-catégories de la catégorie parente, d'où le
name="base_parent_cat" value="9"où "9" est l'id de la catégorie parente.
name="action" : identifie la fonction PHP à appeler côté serveur
→ WordPress cherchera wp_ajax_mon_filtre_ajax et wp_ajax_nopriv_mon_filtre_ajax
#zoneResultats : conteneur où JavaScript injectera le HTML des résultats
[filtre_categories] : shortcode WordPress qui génère les checkboxes de taxonomie
→ Paramètres : taxonomy (nom), parent (ID du terme parent)
[filtre_acf_checkbox] : shortcode qui génère les checkboxes d'un champ ACF
→ Paramètre : field (nom du champ ACF)
Ces shortcodes sont générés dans le script php.
Plusieurs options selon votre configuration :
Maintenant que le formulaire HTML est en place, voyons comment JavaScript l'exploite.
Nous allons créer un fichier filtre-facettes.js ayant pour rôle d'intercepter les changements sur les checkboxes et d'envoyer le formulaire en AJAX.
/**
* =========================================================
* FILTRE À FACETTES - Script AJAX
* =========================================================
*
* Fonctionnement :
* - Soumet le formulaire en AJAX à chaque changement de checkbox
* - Met à jour #zoneResultats avec le HTML retourné par PHP
* - Affiche un état de chargement pendant la requête
*
* Pré-requis :
* - jQuery chargé
* * - MonFiltreAjax.ajaxUrl et MonFiltreAjax.nonce (définis via wp_localize_script (voir section PHP ci-dessous))
*
* =========================================================
*/
jQuery(function ($) {
const $form = $("#monFormulaireFiltres");
const $zone = $("#zoneResultats");
if (!$form.length || !$zone.length) return;
// Lance une première recherche au chargement
lancerRecherche();
// Relance à chaque changement d'une checkbox
$form.on("change", "input[type='checkbox']", function () {
lancerRecherche();
});
function lancerRecherche() {
// Feedback visuel : état de chargement
$zone.addClass("is-loading");
$.ajax({
url: MonFiltreAjax.ajaxUrl,
type: "POST",
dataType: "json",
// Envoie : action + nonce + tous les champs cochés
data: $form.serialize() + "&nonce=" + MonFiltreAjax.nonce,
success: function (res) {
if (
res &&
res.success &&
res.data &&
typeof res.data.html !== "undefined"
) {
$zone.html(res.data.html);
} else {
$zone.html("<p>Erreur : réponse inattendue.</p>");
console.warn("Réponse inattendue", res);
}
},
complete: function () {
// Retirer l'état de chargement dans tous les cas
$zone.removeClass("is-loading");
},
});
}
});
MonFiltreAjax.ajaxUrl et MonFiltreAjax.nonce
Ces variables sont injectées par PHP via wp_localize_script().
Elles contiennent :
/wp-admin/admin-ajax.php).serialize()
Transforme le formulaire en chaîne de caractères exploitable :
action=mon_filtre_ajax&base_parent_cat=9&cat_45=45&acf_wifi=wifi
Seules les checkboxes cochées sont envoyées.
&nonce=
Ajout manuel du nonce à la requête pour la sécurité CSRF. Sans ce token, la fonction PHP refusera d'exécuter la requête.
.is-loading
Classe CSS ajoutée pendant la requête pour afficher un feedback visuel (spinner, opacité réduite, etc.). Retirée dans complete() même en cas d'erreur.
lancerRecherche() au chargement
Affiche immédiatement les résultats si l'utilisateur arrive avec des filtres pré-cochés (par exemple via URL ou sauvegarde de session).
/* Effet de chargement sur la zone résultats */
.is-loading {
opacity: 0.5;
pointer-events: none;
position: relative;
}
.is-loading::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 30px;
height: 30px;
margin: -15px 0 0 -15px;
border: 3px solid #ccc;
border-top-color: #333;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
La fonction PHP reçoit les données du formulaire via AJAX, construit une requête WP_Query avec les filtres taxonomie et ACF, puis renvoie le HTML des résultats au script Javascript.
Dans cet exemple nous avons créé un plugin spécifique.
<?php
/*
Plugin Name: Filtres à facettes AJAX
Description: Système de filtres AJAX par taxonomie et champs ACF
Version: 1.0.0
Author: Yannick Abaul
*/
/**
* =========================================================
* 1) Enqueue du JS
* =========================================================
* - Charge le fichier JS du filtre.
* - Fournit l'URL admin-ajax.php au JS via wp_localize_script().
*/
add_action('wp_enqueue_scripts', function () {
// chemin à adapter si nécessaire
wp_enqueue_script(
'mon-filtre-facettes',
plugin_dir_url(__FILE__) . '/assets/js/filtre-facettes.js',
['jquery'],
'1.0.0',
true
);
// Passe au JS l'URL AJAX (et éventuellement des paramètres)
wp_localize_script('mon-filtre-facettes', 'MonFiltreAjax', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('mon_filtre_nonce'),
]);
});
/**
* =========================================================
* 2) Shortcode : filtre sur taxonomie hiérarchique
* =========================================================
* Usage :
* [filtre_categories taxonomy="categorie" parent="123"]
*
* - Génère les checkboxes des enfants d'un terme "parent"
* - name="cat_<term_id>"
*/
add_shortcode('filtre_categories', function ($atts) {
$atts = shortcode_atts([
'taxonomy' => 'categorie',
'parent' => 0, // ID du terme parent
'hide_empty' => false,
], $atts);
$taxonomy = sanitize_key($atts['taxonomy']);
$parent_id = (int) $atts['parent'];
$hide_empty = filter_var($atts['hide_empty'], FILTER_VALIDATE_BOOLEAN);
$terms = get_terms([
'taxonomy' => $taxonomy,
'parent' => $parent_id,
'hide_empty' => $hide_empty,
'orderby' => 'name',
'order' => 'ASC',
]);
if (is_wp_error($terms) || empty($terms)) {
return '<!-- Aucun terme à afficher -->';
}
$html = '<div class="bloc-facette bloc-facette-taxo">';
foreach ($terms as $term) {
$html .= '<label class="facet-item">';
$html .= '<input type="checkbox" name="cat_' . esc_attr($term->term_id) . '" value="' . esc_attr($term->term_id) . '"> ';
$html .= esc_html($term->name);
$html .= '</label>';
}
$html .= '</div>';
return $html;
});
/**
* =========================================================
* 3) Helper : récupérer les "choices" d'un champ ACF par son nom
* =========================================================
* Pourquoi ?
* - get_field_object('nom_du_champ') nécessite souvent un contexte (post_id).
* - Ici on veut juste les choices définies dans ACF pour générer les checkboxes.
*
* @param string $field_name Nom du champ ACF (ex: 'caracteristiques')
* @return array Tableau associatif [clé => label] des choices, ou tableau vide
*
* @example
* $choices = mon_get_acf_field_choices_by_name('caracteristiques');
* // Retourne : ['wifi' => 'WiFi', 'parking' => 'Parking', ...]
*/
function mon_get_acf_field_choices_by_name($field_name) {
$field_name = sanitize_key($field_name);
// Cache transitoire (1 heure)
$cache_key = 'acf_choices_' . $field_name;
$cached = get_transient($cache_key);
if ($cached !== false) {
return $cached;
}
$acf_fields = get_posts([
'post_type' => 'acf-field',
'posts_per_page' => -1,
'no_found_rows' => true,
'fields' => 'all',
]);
$choices = [];
foreach ($acf_fields as $acf_post) {
if (!isset($acf_post->post_excerpt) || $acf_post->post_excerpt !== $field_name) {
continue;
}
$field = @unserialize($acf_post->post_content);
if (is_array($field) && isset($field['choices']) && is_array($field['choices'])) {
$choices = $field['choices']; // tableau : [cle => label]
break; // On a trouvé, on sort de la boucle
}
}
// Stocker en cache (même si vide, pour éviter des requêtes inutiles)
set_transient($cache_key, $choices, HOUR_IN_SECONDS);
return $choices;
}
/**
* =========================================================
* 4) Shortcode : filtre ACF (checkbox)
* =========================================================
* Usage :
* [filtre_acf_checkbox field="caracteristiques"]
*
* - Génère une checkbox par choice ACF
* - name="acf_<cle>"
*/
add_shortcode('filtre_acf_checkbox', function ($atts) {
$atts = shortcode_atts([
'field' => 'caracteristiques',
], $atts);
$field_name = sanitize_key($atts['field']);
$choices = mon_get_acf_field_choices_by_name($field_name);
if (empty($choices)) {
return '<!-- Aucun choix ACF -->';
}
$html = '<div class="bloc-facette bloc-facette-acf">';
foreach ($choices as $key => $label) {
$html .= '<label class="facet-item">';
$html .= '<input type="checkbox" name="acf_' . esc_attr($key) . '" value="' . esc_attr($key) . '"> ';
$html .= esc_html($label);
$html .= '</label>';
}
$html .= '</div>';
return $html;
});
/**
* =========================================================
* 5) AJAX : filtre (taxonomie + ACF checkbox)
* =========================================================
* - Reçoit les données du formulaire via POST
* - Construit une WP_Query
* - Renvoie { html: "..." } au JS
*/
add_action('wp_ajax_mon_filtre_ajax', 'mon_filtre_ajax_function');
add_action('wp_ajax_nopriv_mon_filtre_ajax', 'mon_filtre_ajax_function');
function mon_filtre_ajax_function() {
// Vérification du nonce (protection CSRF)
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mon_filtre_nonce')) {
wp_send_json_error(['message' => 'Sécurité : nonce invalide.']);
}
// Base de requête
$args = [
'post_type' => 'produit',
'post_status' => 'publish',
'posts_per_page' => 24,
'tax_query' => ['relation' => 'AND'],
'meta_query' => ['relation' => 'AND'],
];
/**
* =========================================================
* 1) Contexte "page catégorie" : base_parent_cat
* =========================================================
* On part du principe qu'on est sur une page catégorie,
* donc on contraint toujours la recherche au parent (et enfants).
*/
$base_parent = isset($_POST['base_parent_cat']) ? (int) $_POST['base_parent_cat'] : 0;
if ($base_parent > 0) {
$args['tax_query'][] = [
'taxonomy' => 'categorie',
'field' => 'term_id',
'terms' => [$base_parent],
'operator' => 'IN',
'include_children' => true,
];
}
/**
* =========================================================
* 2) Sous-catégories cochées : cat_<term_id>
* =========================================================
* - OR dans la facette
* - mais toujours sous la contrainte base_parent
*/
$selected_term_ids = [];
foreach ($_POST as $k => $v) {
if (preg_match('~^cat_(\d+)$~', $k, $m)) {
$selected_term_ids[] = (int) $m[1];
}
}
if (!empty($selected_term_ids)) {
$args['tax_query'][] = [
'taxonomy' => 'categorie',
'field' => 'term_id',
'terms' => $selected_term_ids,
'operator' => 'IN',
'include_children' => true,
];
}
// ⚠️ Ici on ne fait PAS unset(tax_query) si rien n'est coché,
// car on veut conserver la contrainte de base (page catégorie).
/**
* =========================================================
* 3) Filtre ACF checkbox : acf_<cle>
* =========================================================
* - ACF checkbox est stocké en tableau sérialisé
* - compare LIKE sur "cle" (entre guillemets)
* - AND : toutes les options cochées doivent être présentes
*/
$selected_keys = [];
foreach ($_POST as $k => $v) {
if (preg_match('~^acf_(.+)$~', $k, $m)) {
$selected_keys[] = sanitize_text_field($m[1]);
}
}
if (!empty($selected_keys)) {
foreach ($selected_keys as $key) {
$args['meta_query'][] = [
'key' => 'caracteristiques', // nom du champ ACF (adapter si besoin)
'value' => '"' . $key . '"',
'compare' => 'LIKE',
];
}
}
// Si meta_query n'a que la relation, on la supprime pour alléger la requête
if (isset($args['meta_query']) && is_array($args['meta_query']) && count($args['meta_query']) === 1) {
unset($args['meta_query']);
}
/**
* =========================================================
* 4) Exécution + rendu HTML minimal
* =========================================================
*/
$q = new WP_Query($args);
if ($q->have_posts()) {
$html = '<div class="resultats-recherche">';
while ($q->have_posts()) {
$q->the_post();
$html .= "<a class='resultat-item' href='" . esc_url(get_permalink()) . "'>";
$html .= "<div class='carte-produit'>";
$html .= "<div class='image-produit'>" . get_the_post_thumbnail(get_the_ID(), 'thumbnail') . "</div>";
$html .= "<div class='titre-produit'>" . esc_html(get_the_title()) . "</div>";
$html .= "</div>";
$html .= "</a>";
}
$html .= '</div>';
} else {
$html = "<p>Aucun élément ne correspond à votre recherche.</p>";
}
wp_reset_postdata();
wp_send_json_success(['html' => $html]);
}
Hooks AJAX : wp_ajax_ et wp_ajax_nopriv_
Les deux hooks sont nécessaires pour que les filtres fonctionnent :
wp_ajax_ : utilisateurs connectéswp_ajax_nopriv_ : visiteurs non connectésSans nopriv, seuls les administrateurs connectés pourront filtrer.
Vérification du nonce
Le token envoyé par JavaScript est vérifié côté serveur pour empêcher les requêtes CSRF (Cross-Site Request Forgery).
include_children => true
Essentiel pour les taxonomies hiérarchiques. Exemple :
Hébergements (ID: 9) ├─ Appartements (ID: 45) ├─ Maisons (ID: 46) └─ Camping (ID: 47)
Avec include_children => true sur le terme 9 : retourne tous les posts des enfants.
Sans cette option : retourne uniquement les posts directement liés au terme parent.
operator => 'IN' (logique OR)
Si l'utilisateur coche "Hôtels" ET "Maisons", les posts ayant au moins l'une de ces catégories seront affichés.
SQL généré : term_id IN (45, 46)
relation => 'AND'
Entre les différents filtres (taxonomie + ACF), tous les critères doivent être respectés simultanément.
ACF checkbox : LIKE '"wifi"'
Un champ ACF checkbox est stocké en base comme un tableau PHP sérialisé :
a:3:{i:0;s:4:"wifi";i:1;s:7:"parking";i:2;s:3:"bar";}
Pour chercher si "wifi" est présent, on utilise LIKE '%"wifi"%'. Les guillemets sont essentiels pour matcher la valeur exacte et éviter les faux positifs (par exemple "wifimax").
Logique AND sur ACF
Si l'utilisateur coche "WiFi" ET "Parking", seuls les posts ayant les deux caractéristiques seront affichés (chaque meta_query est ajoutée séparément).
Échappement des données
esc_url() pour les URLsesc_html() pour le textesanitize_text_field() pour les inputs utilisateurToujours échapper les données pour éviter les failles XSS.
Note : Le helper `mon_get_acf_field_choices_by_name()` utilise un **transient** (cache d'1 heure) pour éviter de requêter la base de données à chaque affichage.
Vous disposez maintenant d'un système de filtres à facettes AJAX fonctionnel qui permet de filtrer vos contenus WordPress par taxonomie hiérarchique et par champs ACF.
Cette solution convient parfaitement pour des moteurs de recherche de complexité moyenne : catalogues de produits, annuaires ou portfolios. Pour des besoins plus avancés, il pourrait être enrichi avec une pagination AJAX, un système de debounce pour limiter les requêtes serveur, des filtres par plage de prix ou par date. Mais pour la majorité des cas d'usage, cette base fonctionnelle devrait largement suffire.
L'avantage de cette approche : vous gardez la main sur l'ensemble du code, vous pouvez l'adapter précisément à vos besoins métier.