amphibee / meilisearch-facets
Système de filtres/facettes Meilisearch réutilisable pour les projets Pollora (Laravel + WordPress).
Package info
github.com/AmphiBee/MeiliSearchFacets
Type:wordpress-plugin
pkg:composer/amphibee/meilisearch-facets
Requires
- php: ^8.4
This package is auto-updated.
Last update: 2026-04-03 12:34:01 UTC
README
Plugin Composer réutilisable pour ajouter un système de filtres/facettes Meilisearch sur n'importe quel listing d'un projet Pollora (Laravel + WordPress).
Table des matières
- 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
Prérequis
- 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
Installation
Via Composer (Satis privé — méthode recommandée)
composer require amphibee/meilisearch-facets
Le ServiceProvider est auto-découvert par Laravel.
Installation temporaire (développement)
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.
Publier la config
php artisan vendor:publish --tag=meilisearch-facets-config
Cela crée config/meilisearch-facets.php dans le projet.
Configuration
Variables d'environnement (.env)
MEILI_HOST=http://localhost:7700 MEILI_KEY=votre_master_key_ou_search_key MEILI_INDEX_NAME=posts MEILI_MATCHING_STRATEGY=last
config/meilisearch-facets.php
return [ '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'), ], ];
Indexer les posts
Après installation de MeiliScout, indexer les posts WordPress :
ddev exec wp meiliscout index --clear
Créer un listing facetté en 6 étapes
L'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.
Étape 1 — Implémenter SearchConfigInterface
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).
Étape 2 — Enregistrer les handlers
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.
Étape 3 — Configurer l'index Meilisearch
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.
Étape 4 — Créer les vues du thème
Le plugin appelle deux vues Blade qui doivent exister dans le thème. Sans elles, les blocs Gutenberg ne rendent rien.
resources/views/blocks/meilisearch/grid.blade.php
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>
resources/views/blocks/meilisearch/filter.blade.php
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> @endif
resources/views/components/meilisearch/card.blade.php
Composant 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.).
Enregistrement du composant Alpine.js
À 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();
Étape 5 — Configurer le bloc "Listing facetté"
- 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.
Étape 6 — Ajouter des filtres (blocs enfants)
À 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 |
Référence — Les types de filtres Gutenberg
Types d'affichage
| 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 |
Types de données
taxonomy — Taxonomie WordPress
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
post_type — Type de contenu
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
meta_boolean — Méta booléen
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
Référence — SearchConfigInterface
| 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.
Référence — Conventions de nommage des inputs
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 ✕.
Cas d'usage avancés
Filtres custom (post_type, meta_boolean)
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.
1. Déclarer les filtres dans la config
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; }
2. S'assurer que les metas sont bien indexées
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).
3. Configurer les attributs filtrables dans Meiliscout
Dans l'admin Meiliscout, ajouter les clés sans préfixe (Meiliscout ajoute metas. automatiquement) :
multisiteProjectrelated_solutions
Puis relancer l'indexation :
wp meiliscout index --clear
Filtres numériques (prix, durée...)
Les 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.
1. Pousser des metas numériques dans l'index
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-products
2. Déclarer les plages dans la config
public 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), ], ]; }
3. Inputs HTML dans le template Blade du bloc
{{-- 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>
Listing sans filtres numériques
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(); } }
Pagination personnalisée
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(); }
Plusieurs listings sur un même projet
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
Implémentation manuelle (sans Gutenberg)
Si vous n'utilisez pas les blocs Gutenberg, vous pouvez construire le template Blade manuellement et initialiser le composant Alpine.js directement.
Template Blade
{{-- 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é.
Enregistrement du composant Alpine.js
// 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';
URLs partageables (filtres pré-appliqués au chargement initial)
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.
Architecture interne
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
Flux d'une requête AJAX
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)
Résolution de problèmes
Le composant JS ne se déclenche pas
- Vérifier que
Alpine.data('MeilisearchFacets', MeilisearchFacets)est bien appelé avantAlpine.start(). - Vérifier que
data-ajax-actionest présent sur le conteneurx-data.
404 sur admin-ajax.php
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') }}" >
Les requêtes AJAX retournent 0 résultats
- 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).
Les filtres numériques ne fonctionnent pas
- 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.
L'URL ne se met pas à jour
- 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.
Les facettes disponibles ne se désactivent pas
- 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).
Les filtres meta custom ne fonctionnent pas
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.