Plugin Composer réutilisable pour ajouter un système de filtres/facettes Meilisearch sur n'importe quel listing d'un projet Pollora (Laravel + WordPress).
- Prérequis
- Installation
- Configuration
- Créer un listing facetté en 6 étapes
- Référence — Les types de filtres Gutenberg
- Référence — SearchConfigInterface
- Référence — Conventions de nommage des inputs
- Cas d'usage avancés
- Architecture interne
- Résolution de problèmes
- Projet Pollora (Laravel 12 + WordPress 6+)
- Meilisearch accessible (local ou distant)
- Plugin MeiliScout installé et configuré pour indexer les posts WordPress
- Les documents indexés doivent contenir un champ
termsde la forme[{taxonomy, slug, name}] - Les metas numériques filtrables doivent être dans un objet
metas(ex:metas.price)
- Les documents indexés doivent contenir un champ
- AlpineJS chargé dans le thème
composer require amphibee/meilisearch-facetsLe ServiceProvider est auto-découvert par Laravel.
Copier le plugin dans public/content/plugins/meilisearch-facets/ et enregistrer le ServiceProvider dans bootstrap/providers.php :
// bootstrap/providers.php
return [
AmphiBee\MeilisearchFacets\MeilisearchFacetsServiceProvider::class,
App\Providers\AppServiceProvider::class,
// ...
];Puis conditionner chaque handler AJAX à l'activation du plugin dans l'admin WordPress, directement dans le callback #[Action('init')] du Hook Pollora :
#[Action('init')]
public function registerAjaxHandler(): void
{
if (! in_array('meilisearch-facets/meilisearch-facets.php', (array) get_option('active_plugins', []), true)) {
return;
}
FacetsAjaxHandler::register(new MySearchConfig());
}Pourquoi cette séparation ? Dans le cycle de démarrage de Pollora,
bootstrap/providers.phpest évalué avant que WordPress soit chargé. Le ServiceProvider doit donc être enregistré inconditionnellement pour que les bindings Laravel (MeilisearchClient,FacetsSearchService) soient disponibles dans le conteneur. En revanche,get_option()n'est disponible qu'une fois WordPress chargé — ce qui est garanti au moment où#[Action('init')]s'exécute. C'est donc le bon endroit pour vérifieractive_pluginset décider d'enregistrer ou non les actions AJAX.
php artisan vendor:publish --tag=meilisearch-facets-configCela crée config/meilisearch-facets.php dans le projet.
MEILI_HOST=http://localhost:7700
MEILI_KEY=votre_master_key_ou_search_key
MEILI_INDEX_NAME=posts
MEILI_MATCHING_STRATEGY=lastreturn [
'url' => env('MEILI_HOST', 'http://localhost:7700'),
'key' => env('MEILI_KEY'),
'index' => env('MEILI_INDEX_NAME', 'posts'),
'search' => [
'matching_strategy' => env('MEILI_MATCHING_STRATEGY', 'last'),
],
];Après installation de MeiliScout, indexer les posts WordPress :
ddev exec wp meiliscout index --clearL'approche recommandée utilise les blocs Gutenberg : les filtres et la mise en page se configurent directement dans l'éditeur, sans toucher aux templates Blade. La partie PHP reste nécessaire pour la logique de recherche.
L'exemple ci-dessous utilise un CPT reference avec une taxonomie activity-sector et un filtre relationnel.
Créer une classe dans app/Search/ qui étend AbstractSearchConfig.
<?php
// app/Search/ReferenceSearchConfig.php
declare(strict_types=1);
namespace App\Search;
use AmphiBee\MeilisearchFacets\Config\AbstractSearchConfig;
class ReferenceSearchConfig extends AbstractSearchConfig
{
/**
* Nom unique de l'action AJAX pour ce listing.
* Doit être unique dans tout le projet.
*/
public function getAjaxAction(): string
{
return 'myproject_references_facets';
}
/**
* Slug du CPT WordPress ciblé.
*/
public function getPostType(): string
{
return 'reference';
}
/**
* Taxonomies utilisables comme facettes.
* Utiliser les slugs WordPress exacts.
*/
public function getFilterableTaxonomies(): array
{
return ['activity-sector'];
}
/**
* Rendu d'une carte résultat.
* Appelé pour chaque hit retourné par Meilisearch.
* $hit['ID'] contient l'ID WordPress du post.
*/
public function renderHit(array $hit): string
{
return view('components.meilisearch.card', ['post' => get_post($hit['ID'])])->render();
}
/**
* Filtres custom pour les meta non reconnues automatiquement
* (filtres post_type ou meta_boolean configurés dans Gutenberg).
*/
public function getCustomFilters(array $unknownFacets): array
{
$filters = [];
if (! empty($unknownFacets['related_solutions'])) {
$id = (int) $unknownFacets['related_solutions'];
$filters[] = "metas.related_solutions = {$id}";
}
return $filters;
}
}Points importants :
getAjaxAction()doit être unique par listing et par projet.getPostType()doit correspondre exactement au slug du CPT WordPress.getCustomFilters()est requis uniquement si des filtrespost_typeoumeta_booleansont utilisés dans Gutenberg (voir Filtres custom).
Créer un Hook Pollora dans app/Cms/Hooks/Search/ :
<?php
// app/Cms/Hooks/Search/ReferenceFacetsHook.php
declare(strict_types=1);
namespace App\Cms\Hooks\Search;
use AmphiBee\MeilisearchFacets\Ajax\FacetsAjaxHandler;
use AmphiBee\MeilisearchFacets\Registry\FacetedListingRegistry;
use App\Search\ReferenceSearchConfig;
use Pollora\Attributes\Action;
class ReferenceFacetsHook
{
#[Action('init')]
public function registerAjaxHandler(): void
{
if (! in_array('meilisearch-facets/meilisearch-facets.php', (array) get_option('active_plugins', []), true)) {
return;
}
$config = new ReferenceSearchConfig();
// Enregistre l'action WordPress wp_ajax_* pour les requêtes AJAX
FacetsAjaxHandler::register($config);
// Enregistre la config dans le registre pour que le bloc Gutenberg
// puisse retrouver la bonne action AJAX à partir du slug CPT
FacetedListingRegistry::register('reference', $config);
}
}La clé passée à
FacetedListingRegistry::register()doit correspondre exactement au slug CPT qui sera saisi dans la sidebar du bloc Gutenberg.
Pollora découvre automatiquement les classes dans
app/Cms/Hooks/grâce à son système de découverte basé sur les attributs PHP. Aucune déclaration supplémentaire n'est nécessaire.
Lancer la commande Artisan fournie par le plugin pour configurer automatiquement l'index :
php artisan meilisearch-facets:configure "App\Search\ReferenceSearchConfig"Cette commande configure dans l'index Meilisearch :
- Attributs filtrables :
terms.taxonomy,terms.slug,post_type,post_status, + les champs numériques déclarés - Attributs triables :
post_title,post_date, + les champs numériques - Ranking rules :
sorten premier (pour que le tri explicite prime sur la pertinence)
Relancer cette commande à chaque ajout de nouveau champ numérique.
Le plugin appelle deux vues Blade qui doivent exister dans le thème. Sans elles, les blocs Gutenberg ne rendent rien.
Vue du bloc conteneur. Reçoit $attributes (valeurs de la sidebar Gutenberg) et $content (HTML des filtres déjà rendu par les blocs enfants).
@php
use AmphiBee\MeilisearchFacets\Registry\FacetedListingRegistry;
$postType = $attributes['postType'] ?? '';
$gridColumns = $attributes['gridColumns'] ?? '3';
$hitsPerPage = $attributes['hitsPerPage'] ?? 12;
$ajaxAction = FacetedListingRegistry::getAjaxAction($postType);
@endphp
<section
x-data="MeilisearchFacets()"
data-ajax-action="{{ $ajaxAction }}"
data-hits-per-page="{{ $hitsPerPage }}"
x-cloak
>
<div class="filters">
{!! $content !!}
</div>
<div class="grid grid-cols-{{ $gridColumns }}" x-ref="grid"></div>
<div x-ref="pagination"></div>
</section>Vue de chaque bloc filtre enfant. Reçoit $attributes (configuration du filtre dans la sidebar).
@php
$displayType = $attributes['displayType'] ?? 'select';
$dataType = $attributes['dataType'] ?? 'taxonomy';
$source = $attributes['source'] ?? '';
$inputName = $attributes['inputName'] ?? '';
$label = $attributes['label'] ?? '';
$placeholder = $attributes['placeholder'] ?: $label;
$options = match ($dataType) {
'taxonomy' => get_terms(['taxonomy' => $source, 'hide_empty' => true, 'orderby' => 'name']),
'post_type' => get_posts(['post_type' => $source, 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC']),
default => [],
};
@endphp
@if ($dataType === 'meta_boolean')
<label>
<input type="checkbox" name="{{ $inputName }}" value="1">
{{ $label }}
</label>
@elseif ($displayType === 'select')
<div class="filter-group">
<label>{{ $label }}</label>
<select name="{{ $inputName }}">
<option value="">{{ $placeholder }}</option>
@foreach ($options as $option)
<option value="{{ $dataType === 'taxonomy' ? $option->slug : $option->ID }}">
{{ $dataType === 'taxonomy' ? $option->name : $option->post_title }}
</option>
@endforeach
</select>
</div>
@elseif ($displayType === 'radio')
<div class="filter-group">
<label>{{ $label }}</label>
<div class="filter-pills">
<label><input type="radio" name="{{ $inputName }}" value=""> {{ $placeholder }}</label>
@foreach ($options as $option)
<label>
<input type="radio" name="{{ $inputName }}" value="{{ $dataType === 'taxonomy' ? $option->slug : $option->ID }}">
{{ $dataType === 'taxonomy' ? $option->name : $option->post_title }}
</label>
@endforeach
</div>
</div>
@elseif ($displayType === 'checkbox')
<div class="filter-group">
<label>{{ $label }}</label>
<div class="filter-checkboxes">
@foreach ($options as $option)
<label>
<input type="checkbox" name="{{ $inputName }}[]" value="{{ $dataType === 'taxonomy' ? $option->slug : $option->ID }}">
{{ $dataType === 'taxonomy' ? $option->name : $option->post_title }}
</label>
@endforeach
</div>
</div>
@endifComposant carte appelé depuis renderHit() dans chaque SearchConfig. À adapter selon le design du projet.
@props(['post'])
<article class="card">
<a href="{{ get_permalink($post->ID) }}">
@if ($thumbnail = get_the_post_thumbnail_url($post->ID, 'medium'))
<img src="{{ $thumbnail }}" alt="{{ $post->post_title }}">
@endif
<h3>{{ $post->post_title }}</h3>
</a>
</article>Si plusieurs CPT ont des mises en page différentes, chaque SearchConfig peut pointer vers son propre composant (card-reference.blade.php, card-product.blade.php, etc.).
À faire une seule fois dans le JS principal du thème :
// resources/assets/js/frontend/app.js
import Alpine from 'alpinejs';
import MeilisearchFacets from '../../../vendor/amphibee/meilisearch-facets/resources/js/facets.js';
Alpine.data('MeilisearchFacets', MeilisearchFacets);
Alpine.start();- Dans Gutenberg, insérer le bloc "Listing facetté (Meilisearch)"
- Dans la sidebar à droite, renseigner :
| Champ | Description | Exemple |
|---|---|---|
| Type de contenu (CPT slug) | Slug enregistré dans FacetedListingRegistry |
reference |
| Colonnes de la grille | Nombre de colonnes sur desktop | 3 |
| Résultats par page | Nombre de cartes affichées par page | 12 |
La grille et la pagination sont rendues automatiquement. Les blocs enfants définissent les filtres.
À l'intérieur du bloc "Listing facetté", cliquer sur "+" pour ajouter des blocs. Seul le bloc "Filtre de facette" est proposé.
┌─ Bloc "Listing facetté" ─────────────────────────────────────────┐
│ postType=reference, gridColumns=3, hitsPerPage=12 │
│ │
│ ┌─ Bloc "Filtre de facette" ──────────────────────────────┐ │
│ │ displayType=radio, dataType=taxonomy │ │
│ │ source=activity-sector │ → <input type="radio" name="_search_activity-sector">
│ │ inputName=_search_activity-sector │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Bloc "Filtre de facette" ──────────────────────────────┐ │
│ │ displayType=select, dataType=post_type │ │
│ │ source=solution │ → <select name="related_solutions">
│ │ inputName=related_solutions │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ [Grille de résultats — remplie via AJAX] │
│ [Pagination] │
└──────────────────────────────────────────────────────────────────┘
Chaque filtre se configure dans la sidebar :
| Champ | Description |
|---|---|
| Type d'affichage | Comment les options sont rendues (select, radio, checkbox) |
| Type de données | D'où viennent les options (taxonomy, post_type, meta_boolean) |
| Source | Slug de la taxonomie ou du CPT |
| Nom de l'input | Nom HTML — doit respecter les conventions (voir ci-dessous) |
| Label | Intitulé visible dans l'interface |
| Texte de l'option vide | Texte du "Tous" / option par défaut |
| Type | Rendu HTML | Sélection | Idéal pour |
|---|---|---|---|
select |
<select> |
Unique | Taxonomies avec beaucoup de termes |
radio |
Boutons pilules horizontaux | Unique | Taxonomies avec peu de termes (~5), inclut "Tous" automatiquement |
checkbox |
Cases à cocher verticales | Multiple (tableau) | Sélection de plusieurs valeurs |
Charge les termes via get_terms(). Le nom de l'input doit commencer par _search_ suivi du slug de la taxonomie.
dataType : taxonomy
source : activity-sector
inputName : _search_activity-sector
Charge les posts d'un CPT via get_posts(). La valeur envoyée est l'ID du post.
Le nom de l'input correspond au champ méta stocké dans Meilisearch.
Nécessite une implémentation dans getCustomFilters().
dataType : post_type
source : solution
inputName : related_solutions
Affiche une case à cocher unique. Quand cochée, envoie la valeur 1.
Nécessite une implémentation dans getCustomFilters().
dataType : meta_boolean
source : (laisser vide)
inputName : multisiteProject
label : Projets multi-sites uniquement
| Méthode | Retour | Obligatoire | Description |
|---|---|---|---|
getAjaxAction() |
string |
✅ | Nom unique de l'action WP AJAX |
getPostType() |
string |
✅ | Slug du CPT WordPress |
getFilterableTaxonomies() |
array |
✅ | Slugs des taxonomies filtrables |
renderHit(array $hit) |
string |
✅ | HTML d'une carte (hit Meilisearch) |
getNumericRangeGroups() |
array |
— | Groupes de plages numériques |
getCustomFilters(array $unknownFacets) |
array |
— | Filtres Meilisearch pour les meta non reconnus |
getHitsPerPage() |
int |
— | Résultats par page (défaut: 9) |
getDefaultSort() |
array |
— | Tri par défaut Meilisearch |
getFilterableAttributes() |
array |
— | Attributs filtrables (calculé auto) |
getSortableAttributes() |
array |
— | Attributs triables (calculé auto) |
renderPagination(...) |
string |
— | HTML de pagination (défaut: simple) |
Les méthodes sans ✅ ont une implémentation par défaut dans AbstractSearchConfig.
Le composant JS interprète les inputs du formulaire selon ces conventions :
Pattern du name |
Rôle | Exemple |
|---|---|---|
_search_{taxonomy} |
Filtre par taxonomie (automatique) | _search_activity-sector |
{groupe}_range |
Filtre par plage numérique | price_range, duration_range |
search_query |
Recherche textuelle libre | — |
order |
Tri des résultats | — |
Tout autre name |
Meta custom → transmis à getCustomFilters() |
related_solutions, multisiteProject |
Les clés des groupes dans
getNumericRangeGroups()doivent correspondre exactement auxnamedes inputs HTML.
Comportement de la recherche textuelle : l'input name="search_query" doit être de type search ou text. Le composant écoute keydown + Enter (cross-browser) et input lorsque le champ est vidé via le bouton ✕.
Les filtres de type post_type et meta_boolean configurés dans Gutenberg arrivent dans getCustomFilters() de la SearchConfig. Ils doivent y être transformés en expressions de filtre Meilisearch valides.
public function getCustomFilters(array $unknownFacets): array
{
$filters = [];
// Relationnel : valeur = ID du post lié
if (! empty($unknownFacets['related_solutions'])) {
$id = (int) $unknownFacets['related_solutions'];
$filters[] = "metas.related_solutions = {$id}";
}
// Booléen : coché = valeur "1", non coché = absent du tableau
if (! empty($unknownFacets['multisiteProject'])) {
$filters[] = 'metas.multisiteProject = 1';
}
return $filters;
}Meiliscout indexe les metas via get_post_meta($id, $key, true), ce qui retourne uniquement la première valeur pour les champs multi-valeur. Pour les champs stockant plusieurs IDs (Meta Box "Multiple Post"), utiliser le filtre meiliscout/post/document :
#[Filter('meiliscout/post/document', priority: 10)]
public function normalizeDocument(array $document, WP_Post $post): array
{
if ($post->post_type !== 'your_post_type') {
return $document;
}
// Booléen : forcer la présence du champ même si jamais coché
$value = get_post_meta($post->ID, 'multisiteProject', true);
$document['metas']['multisiteProject'] = ($value !== '') ? (int) $value : 0;
// Multi-valeur : get_post_meta(..., false) retourne toutes les valeurs
$document['metas']['related_solutions'] = array_values(
array_map('intval', array_filter((array) get_post_meta($post->ID, 'related_solutions', false)))
);
return $document;
}Pourquoi ne pas lire
$document['metas']? Lors de l'indexation en temps réel (PostSingleIndexer), Meiliscout crée une nouvelle instance dePostIndexabledont$metaKeysest vide —getMetaData()retourne donc[]. En appelantget_post_meta()directement, le hook fonctionne dans les deux contextes (indexation bulk et temps réel).
Dans l'admin Meiliscout, ajouter les clés sans préfixe (Meiliscout ajoute metas. automatiquement) :
multisiteProjectrelated_solutions
Puis relancer l'indexation :
wp meiliscout index --clearLes filtres numériques ne sont pas encore configurables depuis Gutenberg — ils se déclarent dans la SearchConfig et les inputs HTML correspondants sont gérés dans le template Blade du bloc.
Si Meiliscout n'indexe pas automatiquement vos metas numériques dans un objet metas, créer une commande Artisan pour le faire :
<?php
// app/Console/Commands/SyncProductPricesToMeilisearch.php
namespace App\Console\Commands;
use AmphiBee\MeilisearchFacets\Client\MeilisearchClient;
use Illuminate\Console\Command;
class SyncProductPricesToMeilisearch extends Command
{
protected $signature = 'search:sync-products';
protected $description = 'Synchronise les metas numériques des produits vers Meilisearch';
public function handle(MeilisearchClient $client): int
{
global $wpdb;
$results = $wpdb->get_results("
SELECT p.ID, pm.meta_value as price
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = 'price'
WHERE p.post_type = 'product' AND p.post_status = 'publish'
");
$documents = array_map(fn($row) => [
'ID' => (int) $row->ID,
'metas' => ['price' => (int) $row->price],
], $results);
foreach (array_chunk($documents, 100) as $batch) {
$client->updateDocuments(config('meilisearch-facets.index'), $batch);
}
$this->info(sprintf('%d documents mis à jour.', count($documents)));
return Command::SUCCESS;
}
}php artisan search:sync-productspublic function getNumericRangeGroups(): array
{
return [
'price_range' => [
NumericRange::between('0-50', 'metas.price', 0, 50),
NumericRange::between('50-100', 'metas.price', 50, 100),
NumericRange::above('200+', 'metas.price', 200),
],
'duration_range' => [
NumericRange::between('1-7', 'metas.days', 1, 7),
NumericRange::between('8-14', 'metas.days', 8, 14),
NumericRange::above('15+', 'metas.days', 15),
],
];
}{{-- price_range doit correspondre exactement à la clé du groupe --}}
<label><input type="checkbox" name="price_range" value="0-50"> 0 – 50 €</label>
<label><input type="checkbox" name="price_range" value="50-100"> 50 – 100 €</label>
<label><input type="checkbox" name="price_range" value="200+"> 200 € et plus</label>Si votre listing filtre uniquement par taxonomies, getNumericRangeGroups() retourne [] par défaut dans AbstractSearchConfig — rien à faire.
class ArticleSearchConfig extends AbstractSearchConfig
{
public function getAjaxAction(): string { return 'myproject_articles_facets'; }
public function getPostType(): string { return 'post'; }
public function getFilterableTaxonomies(): array
{
return ['category', 'post_tag'];
}
public function getHitsPerPage(): int { return 10; }
public function renderHit(array $hit): string
{
return view('components.meilisearch.card', ['post' => get_post($hit['ID'])])->render();
}
}Surcharger renderPagination() pour utiliser un composant Blade existant :
public function renderPagination(int $totalPages, int $currentPage, string $link): string
{
return view('components.utilities.pagination', [
'total_pages' => $totalPages,
'current_page' => $currentPage,
'link' => $link,
])->render();
}Chaque listing est indépendant : créer une config + un hook par listing.
app/Search/
ReferenceSearchConfig.php → action: 'myproject_references_facets'
SolutionSearchConfig.php → action: 'myproject_solutions_facets'
ArticleSearchConfig.php → action: 'myproject_articles_facets'
app/Cms/Hooks/Search/
ReferenceFacetsHook.php
SolutionFacetsHook.php
ArticleFacetsHook.php
Si vous n'utilisez pas les blocs Gutenberg, vous pouvez construire le template Blade manuellement et initialiser le composant Alpine.js directement.
{{-- themes/{theme}/resources/views/blocks/listings/products.blade.php --}}
<section
x-data="MeilisearchFacets()"
data-ajax-action="myproject_products_facets"
x-cloak
>
{{-- Filtre par taxonomie --}}
<select name="_search_product-category">
<option value="">Toutes les catégories</option>
@foreach(get_terms(['taxonomy' => 'product-category', 'hide_empty' => true]) as $term)
<option value="{{ $term->slug }}">{{ $term->name }}</option>
@endforeach
</select>
{{-- Filtre numérique par plage de prix --}}
<fieldset>
<legend>Prix</legend>
<label><input type="checkbox" name="price_range" value="0-50"> Moins de 50 €</label>
<label><input type="checkbox" name="price_range" value="50-100"> 50 – 100 €</label>
<label><input type="checkbox" name="price_range" value="100-200">100 – 200 €</label>
<label><input type="checkbox" name="price_range" value="200+"> 200 € et plus</label>
</fieldset>
{{-- Recherche textuelle --}}
<input type="search" name="search_query" placeholder="Rechercher...">
{{-- x-ref="grid" et x-ref="pagination" sont obligatoires --}}
<div class="grid" x-ref="grid"></div>
<div x-ref="pagination"></div>
</section>Points importants :
x-data="MeilisearchFacets()"déclenche automatiquement une première requête AJAX auinitpour remplir la grille.data-ajax-actiondoit correspondre exactement àgetAjaxAction().x-ref="grid"etx-ref="pagination"sont requis — le composant JS y injecte le HTML retourné par le serveur.x-cloakmasque la section jusqu'à ce qu'Alpine soit initialisé.
// themes/{theme}/resources/assets/js/frontend/app.js
import Alpine from 'alpinejs';
import MeilisearchFacets from '../../../../public/content/plugins/meilisearch-facets/resources/js/facets.js';
Alpine.data('MeilisearchFacets', MeilisearchFacets);
Alpine.start();Une fois le plugin installé via Composer dans
vendor/, adapter le chemin :import MeilisearchFacets from '../../../vendor/amphibee/meilisearch-facets/resources/js/facets.js';
Pour que les filtres actifs dans l'URL (ex: ?_search_product-category=tshirt) soient appliqués dès le chargement serveur, ajouter ce hook :
use AmphiBee\MeilisearchFacets\Hooks\QueryIntegration;
use Pollora\Attributes\Filter;
#[Filter('myproject_get_products_query_args', priority: 10)]
public function buildQueryArgs(array $args): array
{
return QueryIntegration::apply($args, new ProductSearchConfig());
}Ce filtre est appliqué dans le template Blade via apply_filters('myproject_get_products_query_args', [...]) avant l'instanciation de WP_Query.
meilisearch-facets/
├── src/
│ ├── MeilisearchFacetsServiceProvider.php # Enregistrement Laravel (singleton, config, commande)
│ ├── Client/
│ │ └── MeilisearchClient.php # Wrapper cURL bas niveau (search, multi-search, settings)
│ ├── Config/
│ │ ├── SearchConfigInterface.php # Contrat à implémenter par projet
│ │ └── AbstractSearchConfig.php # Valeurs par défaut (index, tri, pagination, attributs)
│ ├── DTO/
│ │ ├── NumericRange.php # Plage numérique (min/max, filtres Meili + WP_Query)
│ │ ├── SearchRequest.php # Paramètres d'une recherche (query, filtres, page, tri)
│ │ └── SearchResult.php # Résultat normalisé (hits, total, pages, facettes)
│ ├── Service/
│ │ └── FacetsSearchService.php # Orchestration (search, getAvailableRanges, mapSlugs)
│ ├── Ajax/
│ │ └── FacetsAjaxHandler.php # Handler WP AJAX générique
│ ├── Blocks/
│ │ ├── FacetedListingBlock.php # Bloc Gutenberg conteneur
│ │ └── FacetFilterBlock.php # Bloc Gutenberg filtre enfant
│ ├── Registry/
│ │ └── FacetedListingRegistry.php # Registre CPT slug → SearchConfig
│ ├── Console/
│ │ └── ConfigureIndexCommand.php # php artisan meilisearch-facets:configure
│ └── Hooks/
│ └── QueryIntegration.php # Injection $_GET → WP_Query (chargement initial)
├── config/
│ └── meilisearch-facets.php # Config publiable (url, key, index, strategy)
└── resources/
├── js/
│ ├── facets.js # Composant Alpine.js générique
│ └── blocks/editor.js # HOC InnerBlocks pour l'éditeur Gutenberg
└── blocks/
├── faceted-listing/block.json # Définition du bloc conteneur
└── facet-filter/block.json # Définition du bloc filtre
Utilisateur interagit avec un filtre
│
▼
facets.js — collecte les valeurs des inputs nommés
│ POST { action, facets[], extra_datas{page, search} }
▼
FacetsAjaxHandler::handle()
│
├── parseFacets()
│ ├── taxonomyFilters (_search_*)
│ ├── numericFilters (*_range)
│ ├── sort (order)
│ └── unknownFacets (tout le reste → meta custom)
│
├── SearchConfig::getCustomFilters($unknownFacets)
│
├── FacetsSearchService::search() → hits + pagination + facetDistribution
├── FacetsSearchService::getAvailableRanges() → multi-search (quelles plages ont des résultats)
└── FacetsSearchService::mapSlugsToTaxonomies()
│
▼ JSON { grid HTML, pagination HTML, availableFacets, availableRanges }
│
facets.js — met à jour x-ref="grid", x-ref="pagination"
— désactive les options sans résultats
— met à jour l'URL (pushState)
- Vérifier que
Alpine.data('MeilisearchFacets', MeilisearchFacets)est bien appelé avantAlpine.start(). - Vérifier que
data-ajax-actionest présent sur le conteneurx-data.
Le composant résout l'URL AJAX dans cet ordre de priorité :
data-ajax-urlsur le conteneurwindow.MeilisearchFacetsConfig.ajaxUrlwindow.AOGlobal.ajaxUrl(localisé par Pollora/thème viaAsset::add(...)->localize('AOGlobal', [...]))/wp-admin/admin-ajax.php(fallback — incorrect si WordPress est dans un sous-dossier)
Si la 404 persiste, forcer l'URL directement sur le conteneur :
<div
x-data="MeilisearchFacets()"
data-ajax-action="myproject_references_facets"
data-ajax-url="{{ admin_url('admin-ajax.php') }}"
>- Vérifier que
MEILI_INDEX_NAMEcorrespond à l'index réel dans Meilisearch. - Vérifier que les documents indexés ont bien
post_type = "{votre_cpt}"etpost_status = "publish". - Vérifier que
termsest bien présent dans les documents indexés (relancerwp meiliscout index --clear).
- Relancer
php artisan meilisearch-facets:configure "App\Search\VotreSearchConfig"après ajout des plages. - Vérifier que les documents ont un champ
metas.price(ou autre) en valeur numérique, pas en string. - Relancer la commande de synchronisation des metas si nécessaire.
- C'est normal si aucun filtre n'est actif : l'URL revient à son état de base.
- Vérifier que le serveur ne bloque pas les requêtes avec des query strings non reconnus.
- Vérifier que les
namedes<select>commencent bien par_search_. - Vérifier que
availableFacetsest bien retourné dans la réponse JSON (voir onglet Réseau des DevTools).
Symptôme : La requête AJAX retourne HTTP 200 mais la grille ne change pas, ou une erreur Meilisearch indique que l'attribut n'est pas filtrable.
-
Attribut non filtrable dans Meilisearch : Dans l'admin Meiliscout, ajouter la clé meta sans préfixe (
multisiteProject, pasmetas.multisiteProject). Meiliscout ajoutemetas.automatiquement dansgetIndexSettings(). Relancer ensuitewp meiliscout index --clear. -
Champ absent du document Meilisearch : Pour les champs booléens (Meta Box checkbox), aucune ligne
wp_postmetan'est créée lorsque la case est décochée.gatherMetaKeys()ne découvre jamais la clé → le champ est absent du document. Utiliser le filtremeiliscout/post/documentpour forcer la présence du champ avec la valeur0. -
Seule la première valeur est indexée (champ multi-valeur) : Meta Box stocke chaque ID sélectionné dans une ligne
wp_postmetadistincte.get_post_meta($id, $key, true)retourne uniquement la première. Utiliserget_post_meta($id, $key, false)dans le filtremeiliscout/post/documentpour récupérer toutes les valeurs sous forme de tableau. -
Metas vides lors de l'indexation en temps réel :
PostSingleIndexercrée une nouvelle instance dePostIndexabledont$metaKeysn'est jamais initialisé (seulgetItems()le fait, lors de l'indexation bulk).getMetaData()retourne donc[]. Solution : lire les meta viaget_post_meta()directement dans le hookmeiliscout/post/document.