Skip to content

Recherche ​

La recherche produit s'appuie sur Laravel Scout + Meilisearch. Seul Product est indexé, et tout passe par une façade — le contrat ProductIndexService (indexation, recherche, facettes).

Ce qui est indexé ​

Ce qui entre dans l'index, ce n'est pas le produit parent mais ce qui s'affiche : le produit lui-même quand il n'a pas de déclinaison, ses déclinaisons visibles sinon — le parent d'une famille déclinée en sort, il n'a pas de fiche à lui. Chaque document est un document d'union produit par Product::toSearchableArray() : noms par locale, références de la famille, catégories, valeurs d'attributs, valeurs de caractéristiques (celles de la déclinaison et de son parent, sans quoi filtrer sur « Puissance : 1 500 W » ne rendrait jamais un produit décliné), états de disponibilité et prix HT. S'y ajoutent deux champs qui n'existent que pour trier : reference, celle du produit affiché et elle seule (on ne trie pas sur un tableau), et popularity, le nombre de commandes qui ont retenu la famille. Le rôle de chaque champ côté Meilisearch est déclaré au SearchIndexRegistry — par le cœur (CoreSearchIndex) comme par les packages :

UsageAttributs
Recherche plein texte (searchableAttributes)names, references
Filtres (filterableAttributes)categories, attribute_values, feature_values, availability, prices (+ id, family_id, names, references)
Facettes (facets)categories, attribute_values, feature_values, availability, prices
Tri (sortableAttributes)names, reference, popularity, order_in_categories, prices, variant_rank, created_at

Une clé réenregistrée remplace la précédente : c'est ainsi qu'un projet requalifie un attribut du cœur (le rendre triable, lui retirer sa facette…) sans toucher au cœur.

Les listes searchable, filterable et sortable sont reportées au démarrage dans config('scout.meilisearch.index-settings') par CoreSearchIndex::publishIndexSettings() — le fichier de config est chargé avant les providers, il ne peut donc pas lire un registre encore vide — puis poussées à Meilisearch par scout:sync-index-settings. Les facettes n'en sont pas : elles ne sont pas un réglage d'index mais un paramètre de requête, demandé à chaque recherche — d'où, dans le cœur, un attribut de facette toujours déclaré filtrable. Pour ajouter un champ à l'index, déclarez un attribut au registre (voir registres) : documentFor() rend les valeurs des attributs contribués — ceux qui portent un resolver —, que toSearchableArray() fusionne au document du cœur.

php
use Slab\Framework\Core\Search\Contracts\SearchIndexRegistry;
use Slab\Framework\Models\Product;

$registry->attribute(
    'brand_id',
    fn (Product $product): ?int => $product->brand_id,
    filterable: true,
    facet: true,
);

Chaque déclaration devient un SearchAttribute (la clé, son resolver, ses capacités) ; attributes() en rend la liste — de quoi lire ce que le cœur a déjà posé avant de le compléter ou de le requalifier.

Un attribut indexé n'est pas encore un filtre : reste à dire comment la valeur soumise devient une expression moteur. filter() reçoit cette valeur et rend une expression, une liste d'expressions (cumulées en « et ») ou null ; sort() reçoit le sens (asc/desc) et rend l'expression de tri.

La valeur soumise vient de l'adresse, que n'importe qui peut écrire : un handler la convertit avant de l'insérer dans l'expression (d'où l'intval ci-dessous). Les handlers du cœur montrent la marche à suivre : ce qui n'a pas la forme attendue n'ajoute aucune expression, et la page ne tombe pas.

php
$registry->filter(
    'brand',
    fn (mixed $ids): string => 'brand_id IN ['.implode(',', array_map('intval', (array) $ids)).']',
);

Un tri se déclare de la même façon — à condition d'avoir déclaré l'attribut sortable.

filterKeys() rend les clés de filtre déclarées, celles qu'une liste lit dans son adresse (?price[min]=…, ?attributes[5][]=…). Le robots.txt y ajoute le paramètre de tri et écarte des robots les adresses qui portent l'un ou l'autre : un filtre déclaré par un package en profite sans rien faire de plus (voir Catalogue & produits).

Le remplacement complet du document reste possible en surchargeant la méthode sur votre modèle — voir modèles.

Une ligne par famille ​

Une famille réunit un produit et ses déclinaisons. Chaque déclinaison ayant son document, le listing en montrerait autant de lignes : search() demande donc au moteur de dédupliquer sur family_id (option distinct) — d'où l'obligation de le déclarer filtrable.

Meilisearch garde le document le mieux classé de chaque famille : c'est le tri qui désigne la déclinaison montrée. Un tri qui discrimine dans la famille tranche de lui-même — « prix croissant » montre la famille à son prix le plus bas. Sinon variant_rank départage : 0 pour la déclinaison par défaut, puis l'ordre des autres. Les tris sont empilés dans cet ordre :

  1. le tri demandé (champ:sens), traduit par le registre — à défaut, order_in_categories.{category_id}:asc quand la recherche porte sur une seule catégorie (une query string qui en porte plusieurs retombe sur le tri par défaut plutôt que de tomber tout court) ;
  2. variant_rank:asc ;
  3. created_at:desc, sauf s'il est déjà le tri demandé.

INFO

created_at indexe la date du parent : le tri « nouveauté » classe des familles, jamais les déclinaisons entre elles. Il en va de même de popularity, qui additionne les commandes de toute la famille.

Indexation découplée (hors requête) ​

Le modèle désactive l'auto-sync de Scout : la requête HTTP n'attend jamais Meilisearch. À la place, les observers du domaine (Product, Price, Category, CategoryProduct, Attribute, AttributeValue) émettent l'événement ReindexRequested avec les IDs concernés ; un listener queueable — Slab\Framework\Listeners\ReindexProducts — réindexe en arrière-plan via ProductIndexService::index(). Toucher une déclinaison, c'est toucher sa famille : le service remonte aux têtes de famille concernées et réindexe la famille entière — le parent sort de l'index dès qu'une déclinaison visible le représente, et y revient quand la dernière est retirée.

php
use Slab\Framework\Core\Search\Events\ReindexRequested;

// Réindexer après votre propre logique métier :
ReindexRequested::dispatch([$product->id]);

Un dispatch([]) (liste vide) ne réindexe rien — il ne déclenche pas de réindexation globale.

INFO

popularity se compte sur une fenêtre glissante (shop.catalog.popularity_window) : une famille peut en sortir sans que rien ne la touche. Le job RefreshProductPopularity, planifié chaque nuit, réémet ReindexRequested pour ces familles-là et elles seules — valider une commande réindexe déjà les produits qu'elle porte.

Facettes & filtres ​

Les facettes sont réelles : sous-catégories, fourchette de prix, valeurs d'attributs (multi-sélection) et états de disponibilité. Pour chaque catégorie, l'éventail de filtres applicables est lu dans l'index — ce fut une colonne filters refaite à chaque indexation, elle a été supprimée. Category::filters appelle CategoryService::availableFilters(), qui interroge le moteur en une requête via ProductIndexService::facetsFor() et reçoit un CatalogFacets : les valeurs présentes derrière chaque facette (values(), keys(), keysOfAny()) et les bornes des facettes numériques (bounds(), null quand elles sont plates — un curseur à bornes égales ne filtre rien). L'accès est mémorisé sur l'instance ; il reste une requête au moteur, à ne pas appeler en boucle sur une liste de catégories.

Ce que la catégorie propose est déclaré au CatalogFilterRegistry : add() porte le calcul et les facettes dont il a besoin — le cœur y pose categories, price, attributes et availability (CoreCatalogFilters) —, view() porte le rendu, posé par le thème. Le calcul d'un côté, la vue qui le rend de l'autre : un package ajoute donc un filtre sans toucher ni au service de catégorie ni au thème, et un thème peut redéfinir le rendu d'un filtre du cœur sans toucher à son calcul.

INFO

Un produit est indexé sous ses catégories et leurs ancêtres : categories IN [id] couvre déjà la descendance, et les filtres d'une catégorie portent sur toute sa branche sans travail supplémentaire. Le moteur ne rend que des clés (identifiants de valeur d'attribut, états de disponibilité) : les traduire est l'affaire de chaque source — un libellé indexé figerait la langue de l'indexation et se périmerait au premier renommage.

À la recherche, ces choix sont traduits en syntaxe de filtre Meilisearch par les traductions déclarées au SearchIndexRegistry. Le cœur en livre cinq — category_id et categories (tous deux categories IN [...]), price (prices >= … / prices <= …), attributes et availability (dont les états, chaînes libres, sont cités) — et cinq tris : price, name (sur la locale courante), created_at, reference et popularity (qui se lit décroissant : personne ne cherche le produit que personne n'a commandé), ceux proposés au visiteur via shop.categories.sorts. getFacetedSearch() renvoie les produits et la distribution de facettes du moteur (clé facets), les facettes demandées étant exactement celles du registre et les résultats plafonnés à shop.search.max_results.

price reçoit une fourchette (min, max) dont chaque borne est facultative : une borne absente ou non numérique n'ajoute pas d'expression. Un visiteur qui ne remplit qu'un côté demande bien « à partir de tant » : c'est ce que la colonne de filtres annonce, et un champ laissé vide ne part plus en prices >= pour faire tomber la requête au moteur. Une valeur qui n'est pas une fourchette (?price=100-900) est ignorée.

Deux clés pour la catégorie, parce que les rôles diffèrent : category_id porte celle de la page, posée par le service de catégorie ; categories porte les sous-catégories que le visiteur coche. Les deux expressions se cumulent — cocher une sous-catégorie restreint la page au lieu de la remplacer, on ne sort jamais de la branche affichée.

Il n'y a pas de filtre features : le référentiel d'attributs est unique, seul l'usage qu'un produit fait d'une valeur la range en attribute_values (elle décline) ou en feature_values (elle décrit). attributes interroge donc les deux champs en « ou » — cocher « Tension : 18 V » rend la clé à choc qui s'y décline et la perceuse qui la porte sans s'y décliner. Entre attributs différents, c'est un « et » ; entre les valeurs d'un même attribut, un « ou ». Les identifiants n'entrent dans l'expression qu'en entiers : une valeur forgée dans l'adresse est écartée au lieu de réécrire l'expression ou de la casser, et un attribut dont il ne reste aucune valeur n'ajoute rien.

INFO

Une clé de filtre ou de tri inconnue du registre est ignorée — filterExpressions() rend une liste vide, sortExpression() rend null : un paramètre d'URL inattendu ne fait pas échouer la recherche. Il en va de même d'un handler qui rend null — pas d'expression, donc pas de filtre.

WARNING

getFacetedSearch() passe par search(), donc par distinct : sa facetDistribution est calculée sur les documents retenus — un par famille. Une facette n'y compte que les valeurs des déclinaisons représentées : sur une famille de trois couleurs, celle qui la représente et pas les deux autres. Les filtres de catégorie ne s'y exposent pas — ils passent par facetsFor(), une requête à eux sans distinct et sans résultats à rendre (limit: 0), qui voit donc toutes les valeurs.

TIP

Slab ne pose que searchableAttributes, filterableAttributes et sortableAttributes ; les autres clés de config('scout.meilisearch.index-settings') sont préservées. Synonymes, stop words et ranking rules ne sont donc pas paramétrés : Slab s'en tient aux défauts de Meilisearch. Réglez-les sur l'index si votre catalogue le demande.

Observer les recherches ​

Chaque recherche exécutée par un visiteur émet SearchPerformed avec les termes saisis et le nombre de résultats. Les recherches sans résultat sont le premier gisement d'amélioration d'un catalogue : un package peut les journaliser, suggérer des synonymes ou alimenter un tableau de bord.

php
use Slab\Framework\Core\Search\Events\SearchPerformed;

public function handle(SearchPerformed $event): void
{
    if ($event->results === 0) {
        // $event->terms n'a rien donné : à consigner.
    }
}

Par où l'étendre ​

Je veux…Point d'extensionMécanique
indexer un champ, l'exposer en facette, le filtrer ou le trierSearchIndexRegistryregistres
ajouter un filtre à la page de catégorieCatalogFilterRegistryregistres
remplacer entièrement le document indexétoSearchableArray (modèle Searchable)modèles
réindexer après un changement métierReindexRequestedévénements
réagir aux recherches (sans résultat, statistiques)SearchPerformedévénements
remplacer le moteur ou la stratégie de rechercheProductIndexService (bind)conteneur

Exemple : remplacer le service de recherche ​

Le cœur lie le contrat à DefaultProductIndexService ; rebindez-le pour brancher votre moteur :

php
use Slab\Framework\Core\Product\Contracts\ProductIndexService;

// index(): (ré)indexe · search(): construit la requête Scout · getFacetedSearch(): résultats +
// facettes · facetsFor(): valeurs et bornes d'un périmètre (la matière des filtres de catégorie)
$this->app->bind(ProductIndexService::class, MonMoteurDeRecherche::class);

Commandes utiles ​

CommandeRôle
scout:import "Slab\Framework\Models\Product"(ré)indexe tout le catalogue
scout:sync-index-settingsapplique searchable/filterable/sortable à l'index
meilisearch:reinit --reindexrecrée l'index vide, réapplique les réglages, réindexe
meilisearch:documents {productId?}inspecte les documents réellement indexés

Voir aussi ​