Apparence
Catalogue & produits
Le catalogue couvre les produits, les catégories (arbre imbriqué), les attributs et les variantes. C'est le domaine le plus souvent étendu.
Les modèles
| Modèle | Rôle | À savoir |
|---|---|---|
Product | un produit (ou une variante) | multilingue (name, slug en JSON), indexé (Scout), statut ouvert (ProductStatusRegistry) et stock simple |
Category | catégorie | arbre imbriqué (nested set) ; rattachement many-to-many aux produits |
Attribute / AttributeValue | axes de variation (Couleur → Rouge…) | servent à générer les variantes et à porter les caractéristiques |
Price | historique de prix HT | le prix « courant » est le dernier ; le calcul passe toujours par le pipeline |
Un produit est rattaché à ses catégories via un pivot (is_main, order) — d'où mainCategory() et un ordre par catégorie.
Le prix se lit par le ProductPriceService, scopé au produit (app(ProductPriceService::class, ['product' => $product])) : getMoney(ti, withModifiers) est la source — un Money exact issu du PricePipeline — et getPrice() sa façade scalaire pour l'affichage. addPrice() empile un nouveau prix HT, en ignorant une variation inférieure au centime. Le prix dépend aussi de qui l'on sert : la personne connectée par défaut, ou celle que l'appelant nomme. forCustomer($customer) rend un service lié à ce client-là — null étant le visiteur, sans compte —, l'instance dont il sort continuant de servir la session. C'est ainsi qu'un canal qui n'est pas une requête du navigateur — une IA, une tâche planifiée, un export — annonce le prix que la fiche afficherait à ce client, et non celui d'une session absente. Détail : Pricing & taxe.
Ranger l'arbre et ses produits
Deux gestes du back-office — glisser une catégorie dans l'arbre, glisser un produit dans sa catégorie — sont des services du domaine, et non du code de contrôleur : l'écran et une IA qui en font autant passent par le même chemin.
CategoryTree::move($category, $parent, $position) range une catégorie sous $parent — la racine s'il est omis, c'est-à-dire au premier niveau —, à la position $position parmi ses sœurs (0 pour la première), ou en dernier si la position est omise ou dépasse le nombre de sœurs. Il lève une ValidationException (clé parent) quand le parent est la catégorie elle-même ou l'une de ses sous-catégories. La position se compte dans l'ordre de l'arbre, celui que l'écran montre. Le service relit aussi les nœuds avant de déplacer : les bornes de l'arbre imbriqué changent pour tous les nœuds à chaque déplacement. Sans cette relecture, un modèle chargé avant un autre geste porterait des bornes périmées, et la catégorie atterrirait ailleurs.
CategoryTree::moveBefore($category, $sibling) range la catégorie juste devant $sibling, sous le même parent que lui : « mets Scies avant Perceuses ». Désigner une sœur ne laisse aucune ambiguïté, contrairement à une position chiffrée : c'est la forme qui convient à une IA. Le service lève une ValidationException (clé before) quand $sibling est la catégorie elle-même ou l'une de ses sous-catégories.
« Premier niveau » a le même sens à l'enregistrement : une catégorie créée sans parent, ou dont on vide le parent, se range sous la racine (PlaceUnderRoot). Une modification qui ne dit rien du parent n'y touche pas. La racine est un conteneur technique, que personne ne choisit. Ce hook est joué avant l'enregistrement, parce que l'arbre imbriqué place le nœud au moment où son parent est posé : le corriger après coup le déplacerait une seconde fois. Voir Enregistrer depuis un écran.
ProductRanking::reorder($category, $productIds) pose l'ordre des produits d'une catégorie. Les rangs des produits donnés sont permutés entre eux : relus, triés, puis redistribués dans le nouvel ordre. Les renuméroter de 1 à n écraserait l'ordre des produits qu'on n'a pas nommés, ceux d'une autre page du listing. Un produit qui n'est pas rangé dans la catégorie est ignoré. Côté écran : Ordonner les produits d'une catégorie.
Jamais de rang nul sur le pivot
Le paquet d'ordonnancement écoute le détachement d'une ligne et réordonne ce qui suit (where('order', '>=', …)) : un rang nul fait refuser la requête, et comme sync() détache avant d'attacher, l'erreur laisse le produit amputé de ses catégories. Tout code qui attache un produit à une catégorie pose donc un order — la génération de déclinaisons reprend celui du parent, 1 à défaut.
Les variantes
Une variante est un Product enfant (parent_id pointant le produit parent). Le ProductVariantService — scopé lui aussi au produit — génère les variantes comme le produit cartésien des valeurs d'attributs choisies, groupées par attribut racine : une variante par combinaison, qui hérite des catégories du parent — rang compris (celui du parent, 1 à défaut), sa principale restant la principale —, de ses contenus et de son prix. Recopier les seuls identifiants laisserait la déclinaison sans catégorie principale, et sa fiche ne s'enregistrerait plus : le back-office en réclame une. La première créée devient la default_variant_id du parent s'il n'en avait pas, et generateVariants($values, delete_superfluous: true) supprime au passage les variantes qui ne correspondent plus à aucune combinaison. Si la déclinaison par défaut en faisait partie, la famille en reçoit une nouvelle (Product::restoreDefaultVariant() : la première visible, sinon la première tout court). C'est aussi ce qui se passe quand on supprime une déclinaison seule. Sans valeur d'attribut, il n'y a aucune combinaison et donc rien à faire : ni création, ni suppression. Les variantes sont créées sans événements ; c'est la génération qui se clôt par une réindexation de la famille (ProductIndexService::index($parent)).
Une famille n'a pas de fiche à elle : son url mène à la déclinaison qui la représente, landingVariant(). C'est la déclinaison par défaut si elle est visible, sinon la première visible. Si aucune n'est visible, url vaut null : un lien vers une famille doit prévoir ce cas.
Deux méthodes servent le reste du cycle. applyToVariants($values) répercute sur toutes les variantes les champs cochés (apply_to_variants) de la fiche produit — colonnes, contentables, catégories et prix ; les catégories y passent par le même chemin qu'à la génération, rang compris. Elle part des valeurs validées de l'enregistrement du produit, jamais de la requête entière. siblingAxes() rend les axes de déclinaison de la famille : une Collection de VariantAxis, un axe par attribut qui décline, portant pour chaque valeur la déclinaison qu'elle mène. De quoi relier chaque valeur à la fiche qu'elle ouvre, et traiter à part celle qui n'en ouvre aucune : c'est ce qui grise une taille indisponible dans le sélecteur. Elle rend une collection vide si le produit n'est pas lui-même une déclinaison — il n'a pas de sœurs à proposer.
Deux lectures complètent le contrat : getVariantsAttributeValues() rend les valeurs d'attributs portées par les variantes du produit, et getAttributeValuesTree() l'arbre complet des attributs et de leurs valeurs — utilitaire global, indépendant du produit scopé.
Statut & disponibilité
Deux notions voisines, souvent confondues : le statut est une décision éditoriale — le produit est-il publié ? — la disponibilité un état calculé depuis le stock.
Le statut, ouvert aux packages
ProductStatus n'est pas un enum — il serait fermé, et aucun package ne pourrait y ajouter un cas. C'est un objet déclaré au ProductStatusRegistry. Le cœur n'en connaît que deux (CoreProductStatuses::DRAFT et ::ONLINE) et ne leur demande que deux choses : le produit est-il visible en front, et commandable ?
php
use Slab\Framework\Core\Product\Contracts\ProductStatusRegistry;
use Slab\Framework\Core\Product\ProductStatus;
$this->app->make(ProductStatusRegistry::class)->add(new ProductStatus(
'end_of_line',
fn (): string => __('Fin de série'), // fermeture : la locale n'est pas fixée au boot
visible: true,
orderable: false,
priority: 30,
));Une clé déjà enregistrée est remplacée : c'est ainsi qu'un projet redéfinit les effets d'un statut du cœur sans le supprimer. setDefault() désigne le statut d'un produit qui n'en a pas choisi — le cœur pose Brouillon ; à défaut, le premier statut déclaré fait office de défaut. La priority ordonne all() et list() : un statut ajouté prend sa place dans les listes de choix du back-office sans autre branchement. Un statut se compare par is() (clé ou objet) et se réduit à sa clé par __toString().
La colonne status reste une chaîne — ProductStatusCast la traduit en objet dans les deux sens, une colonne nulle prenant le statut par défaut du registre — donc aucune migration pour un statut ajouté. Et le cœur ne compare jamais à « en ligne » : il interroge les scopes visible() et orderable() du modèle, alimentés par visibleKeys() / orderableKeys() du registre. Un statut venu d'un package est ainsi honoré partout : catalogue, recherche, listes du back-office, panier.
Clé inconnue = produit invisible
get() sur une clé non déclarée — le package qui la posait a été désinstallé — rend un statut de repli ni visible ni commandable : le produit disparaît du front au lieu de faire tomber la page.
La disponibilité, calculée depuis le stock
ProductAvailabilityService::for() rend un ProductAvailability : un état (IN_STOCK, LOW_STOCK, OUT_OF_STOCK), sa commandabilité, la quantité vendable et un message client. L'implémentation par défaut lit le stock simple porté par le produit :
| Colonne | Rôle |
|---|---|
stock_physical | ce qui est en entrepôt ; null = stock non suivi, le produit reste disponible |
stock_reserved | la part déjà promise — stock_available = physique − réservé |
stock_threshold | à ce seuil ou en deçà, l'état passe à LOW_STOCK |
allow_backorder | commandable à zéro : c'est la vente « sur commande » |
out_of_stock_message | message (traduisible) affiché à la place du bouton d'ajout |
$product->availability porte cet état côté thème, $product->is_orderable réunit les deux notions (status->orderable && availability->orderable), et la garde vit dans le service — CartService::addProduct() refuse un produit non commandable — pas dans la vue : masquer un bouton ne protège de rien.
L'état entre aussi dans le document indexé (champ availability), d'où le filtre de catalogue du même nom : il y range des clés et non des libellés, parce que le moteur n'indexe pas de libellés — un libellé indexé fige la langue de l'indexation et se périme au premier renommage. C'est la vue qui les nomme, par ProductAvailability::labelFor(), qui nomme un état dont on n'a que la clé.
Une boutique qui tient son stock ailleurs (WMS, ERP, multi-dépôt) substitue le contrat, et tout suit :
php
use Slab\Framework\Core\Product\Contracts\ProductAvailabilityService;
use Slab\Framework\Core\Product\ProductAvailability;
use Slab\Framework\Models\Product;
final class StockErp implements ProductAvailabilityService
{
public function for(Product $product): ProductAvailability
{
return new ProductAvailability('backorder', orderable: true, available: 0,
message: __('Réapprovisionnement sous 3 semaines'));
}
}
$this->app->bind(ProductAvailabilityService::class, StockErp::class);Côté thème, l'état sort en classe CSS sur la fiche produit (availability--backorder) : styler un état ajouté ne demande pas de surcharger la vue.
Un appel par produit affiché
$product->availability est mémorisé par instance de modèle (Laravel met en cache les accesseurs qui rendent un objet) : le relire sur la fiche ne coûte rien. Mais un listing instancie un produit par ligne, donc autant d'appels au service. Le défaut du cœur ne lit que des colonnes déjà chargées ; une implémentation qui interroge un ERP doit précharger par lot ou porter son propre cache, sous peine d'un aller-retour réseau par ligne.
L'état est une chaîne libre, pas un enum
Un service substitué produit ses propres états ; le cœur les affiche sans les connaître — label() sur l'objet, labelFor() sur une clé nue, rendent la clé telle quelle pour tout état qu'il n'a pas traduit lui-même, et is() compare l'état à une clé.
Par où l'étendre
| Je veux… | Point d'extension | Mécanique |
|---|---|---|
| modifier le prix d'un produit | PriceModifier | pipeline |
| précharger, dans toute liste de produits, ce que mon modificateur lit | ProductListRelations | liste de produits |
| ajouter un statut de produit (« fin de série ») | ProductStatusRegistry | registres |
| ajouter un filtre de catalogue (marque, matière) | CatalogFilterRegistry | registres |
| proposer ces filtres hors d'une catégorie (marque, fournisseur, recherche) | CatalogScope | périmètre |
| filtrer sans requête ce qu'un écran affiche déjà | CatalogFacets::fromDocuments() + proposing() | périmètre |
| choisir les filtres rendus, leur nom, leur ordre, leur forme | CatalogFilterPresenter (bind) | conteneur |
| ajouter une facette, un filtre ou un tri à l'index | SearchIndexRegistry | registres |
| brancher le stock sur un WMS / ERP | ProductAvailabilityService (bind) | conteneur |
| redéfinir ce qui compte comme vente (popularité comprise) | SaleDefinition (bind) | conteneur |
| remplacer la logique de génération des variantes | ProductVariantService (bind) | conteneur |
| changer de moteur de recherche ou de stratégie d'indexation | ProductIndexService (bind) | conteneur |
| remplacer le listing ou le calcul des filtres d'une catégorie | CategoryService (bind) | conteneur |
| relier mes données à un produit / une catégorie | resolveRelationUsing | relations |
| ajouter un champ riche/traduit sans migration | HasContents (contentable) | modèles |
| réagir à un changement du catalogue (réindexer) | ReindexRequested | événements |
| mesurer ou recommander à partir des consultations | ProductViewed, CategoryViewed | événements |
| ajouter un onglet à la fiche produit (BO) | ViewHookRegistry (back.products.edit.tabs) | hooks |
Exemple : relier vos données à un produit
C'est ainsi que slab/promotion greffe ses relations, sans que le cœur connaisse Promotion :
php
Product::resolveRelationUsing('promotions',
fn (Product $product) => $product->belongsToMany(Promotion::class));
Category::resolveRelationUsing('promotions',
fn (Category $category) => $category->belongsToMany(Promotion::class));Exemple : réindexer après un changement métier
L'indexation est découplée : les observers du cœur émettent ReindexRequested, un listener queueable réindexe hors requête. Pour réindexer suite à votre propre logique :
php
use Slab\Framework\Core\Search\Events\ReindexRequested;
ReindexRequested::dispatch([$product->id]);Deux autres événements portent la consultation : ProductViewed (la fiche) et CategoryViewed (la liste, avec les filtres et le tri retenus par le visiteur) — de quoi alimenter un « vu récemment » ou mesurer l'usage réel des facettes sans surcharger le contrôleur du thème.
Recherche & facettes
Product est Searchable (Meilisearch), mais ce qui entre dans l'index n'est pas le produit parent : c'est ce qui s'affiche. ProductIndexService::index() raisonne famille par famille — 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, et le listing doit pouvoir montrer la déclinaison qui coïncide avec les filtres. Un parent invisible emporte toute sa famille : son statut est une décision éditoriale sur le produit entier. Toucher une déclinaison, c'est toucher sa famille : le service remonte d'abord aux têtes de famille — toutes, si on ne lui en passe aucune.
Le listing ne rend pourtant qu'une ligne par famille : la recherche pose distinct: family_id, et le moteur garde le document le mieux classé de chaque famille. C'est donc le tri qui désigne la déclinaison représentée ; quand il ne discrimine pas — les champs de famille sont égaux d'une déclinaison à l'autre par construction — variant_rank:asc tranche (Product::rankInFamily()) : la déclinaison par défaut si elle coïncide avec les filtres, la première sinon.
Ce que le moteur sait faire — attributs recherchables (names, references), facettes, filtres et tris — n'est pas codé en dur dans le service : c'est déclaré au SearchIndexRegistry, où le cœur inscrit les siens (CoreSearchIndex) et où un package ajoute les siens sans remplacer le service. Une clé de filtre inconnue du registre est ignorée : un paramètre d'URL inattendu ne fait pas échouer la recherche. À défaut de tri demandé — ou d'un tri que le moteur sache faire —, les produits sortent dans l'ordre de la catégorie filtrée (order_in_categories.<id>) — une seule catégorie ordonne, celle de la page : une query string forgée qui en porte plusieurs retombe sur le tri par défaut plutôt que de tomber tout court —, les ex æquo étant départagés par le rang de déclinaison puis par date de création décroissante. Le contenu du document indexé, lui, reste toSearchableArray() sur le modèle — c'est lui, notamment, qui indexe un produit sous ses ancêtres, pour qu'une catégorie intermédiaire liste sa descendance. La page de recherche passe par getFacetedSearch(), qui rend les produits et la distribution des facettes, plafonnés par défaut à shop.search.max_results — un plafond taillé pour un panneau de recherche ; un appelant qui pagine lui-même, un outil ou un export, passe le sien en $max_results. Détail : Recherche.
Le tri vient de l'adresse
Le tri demandé s'écrit dans l'URL (?sort=price:asc), donc à la main. Seuls champ:asc et champ:desc vont au moteur, pour un champ que le SearchIndexRegistry sait trier : ?sort=prix ou ?sort=price:n-importe retombent sur l'ordre par défaut au lieu de faire tomber la page. Un contrôleur ou une vue qui lit ce tri — pour le passer au service, ou le recopier dans un champ caché — passe par CatalogSort::requested() : il rend le texte demandé ou null, une valeur forgée en tableau (?sort[]=x) ne désignant aucun tri. Juger qu'un texte désigne un tri que le moteur sait faire reste l'affaire du service d'index.
Le nom du paramètre vit dans CatalogSort::PARAMETER : les listes le lisent, et le robots.txt s'en sert pour écarter des robots les listes triées — comme il écarte les listes filtrées, une règle par filtre déclaré au SearchIndexRegistry, paquets compris. La pagination (p) reste ouverte : la page 2 d'un rayon montre d'autres produits.
Les documents, en plus des produits
L'implémentation du cœur range aussi dans son retour les documents du moteur par identifiant (documents) : un écran qui filtre ce qu'il affiche y lit ce que chaque produit porte sans rien recharger — l'index le sait déjà. Le contrat ne les promet pas : une boutique qui cherche ailleurs rend des produits sans rien dire de ce qui les a classés, et l'écran doit s'en passer ($search->documents ?? []) plutôt que de tomber.
Les produits, eux, sont relus en une requête, préchargés pour la liste qui va les montrer (forListing()), et remis dans l'ordre du moteur — c'est lui qui a classé, la base ne sait que rendre des lignes. Un identifiant que l'index porte encore mais dont la ligne a disparu sort des résultats : la collection est alors plus courte que les documents, et un écran qui les apparie compte sur les produits.
Ce que les facettes disent depuis le distinct
La facetDistribution que rend getFacetedSearch() est calculée sur les documents retenus — un par famille : une facette n'y propose que les valeurs des déclinaisons représentées, pas les trois couleurs d'une famille qui n'en montre qu'une. C'est pourquoi les filtres d'un périmètre ne passent pas par là : facetsFor() pose une requête à elle, sans distinct et sans résultats à rendre (limit: 0), qui les voit toutes. Une extension qui affiche la distribution de la recherche doit, elle, trancher : proposer la liste complète en acceptant des comptes en déclinaisons, ou compter les familles hors du moteur — c'est ce que fait un panneau qui compte sur ses propres documents (CatalogFacets::fromDocuments()).
Le listing passe par le même index : CategoryService::getProducts() délègue à ProductIndexService::search() — qui rend un Scout\Builder — en posant category_id, et pagine selon shop.categories.products_per_page (paramètre d'URL p) ; getProductsCount() rend le total (totalHits) sans paginer. Les filtres reçus sont fusionnés après category_id (array_merge) : un category_id passé dans $filters remplace celui de la catégorie. Comme le service de prix, il est scopé — app(CategoryService::class, ['category' => $category]) — et ses quatre opérations le sont : scope(), availableFilters(), getProducts(), getProductsCount().
Le plafond de search() ne vaut que hors pagination
search() accepte un $max_results, mais il ne pose hitsPerPage que si personne ne l'a déjà posé : dès qu'on demande une page, la pagination de Scout prime — sans quoi le paginateur annoncerait trois pages et la première les montrerait toutes. Le plafond ne joue donc que pour une recherche qu'on ne pagine pas, comme celle de getFacetedSearch().
Ce qu'une liste lit de chaque produit
Une carte de produit lit son visuel, son prix, sa taxe, l'adresse de sa déclinaison ; et le prix passe par les modificateurs, qui lisent leurs propres relations — les promotions du produit et de ses rayons. Lu produit par produit, un rayon de vingt-quatre produits coûtait plus de deux cents requêtes. Le registre ProductListRelations dit donc ce qu'une liste précharge : une requête par relation, au lieu d'une par produit. Le cœur y inscrit contents, prices, taxRule, defaultVariant et parent.contents ; un package y ajoute ce que ses modificateurs lisent, sans que le cœur le connaisse.
Le registre sert par deux portes : le scope forListing() pour une requête (Product::query()->forListing()), qui compte aussi les déclinaisons (withCount('variants')), et Product::prepareListing($products) pour une collection déjà lue — une rangée, des suggestions. Tout résultat du moteur passe par la première : le modèle surcharge queryScoutModelsByIds(), et getFacetedSearch() relit ses produits de même. Le prix courant et has_variants lisent alors ce qui est chargé, sans requête.
php
use Slab\Framework\Core\Product\ProductListRelations;
// Dans le provider — c'est ainsi que slab/promotion précharge ce que son modificateur lit :
$this->callAfterResolving(ProductListRelations::class,
fn (ProductListRelations $relations) => $relations->add('promotions', 'categories.promotions'));Une relation oubliée ne casse rien, elle coûte
Une relation que votre modificateur lit sans l'avoir inscrite se charge produit par produit : le prix reste juste, mais la liste repaie une requête par ligne.
Ce qui se vend : la popularité
Le tri popularity:desc se lit dans le document, comme tous les autres : toSearchableArray() y range ProductPopularity::for($product) — le nombre de commandes qui ont retenu le produit. Des commandes, et non des quantités : une seule commande de cinq cents unités n'a pas à décider du classement, alors que « combien de clients l'ont voulu » se lit pareil quel que soit le métier.
Trois règles font ce compte :
- sur les ventes — jamais le panier ni le placement : ce qui compte comme vente est dit une fois pour toute la boutique par le contrat
SaleDefinition, et le chiffre d'affaires retient les mêmes commandes. Par défaut (DefaultSaleDefinition), une vente est une commande validée et jamais annulée (validated_atposé,cancelled_atnul), datée de sa validation : une commande validée puis annulée n'a rien vendu et ne rend personne populaire ; - par famille (
COALESCE(parent_id, id)) — les ventes des déclinaisons s'additionnent et chaque document de la famille porte le même compte, puisque le listing n'en montre qu'une ligne ; deux déclinaisons dans la même commande restent un client, pas deux ; - sur une fenêtre glissante —
shop.catalog.popularity_window, en jours (90 par défaut), lue sur la date de la vente (SaleDefinition::dateColumn()) : vide, le compte porte sur tout l'historique et le classement ne bouge presque plus.
Une boutique qui ne compte une vente qu'une fois réglée lie sa propre SaleDefinition — constrain() restreint une requête sur les commandes, dateColumn() dit la colonne qui date la vente — et la popularité la suit sans rien redéfinir.
Le compte se lit en une requête groupée (ProductPopularity::counts(), restreignable à une liste de familles) et reste mémorisé pour la durée du processus : l'indexation appelle for() une fois par document, et une requête par document referait l'erreur déjà payée sur les sources de filtres. Ce qui l'invalide déclenche de toute façon une réindexation — une commande qui devient une vente ou cesse de l'être (annulée après validation) oublie le mémo (forget()) et réindexe les familles entières de ses produits (Product::familyIds()), et un job nocturne (RefreshProductPopularity) rattrape les seules familles que le temps vient de faire sortir de la fenêtre, celles-là seules ayant changé sans que rien ne les touche.
Le périmètre de catalogue
Une catégorie n'est pas le seul découpage du catalogue : une marque, un fournisseur, une recherche en restreignent un autre — et proposent la même colonne de filtres. Or le calcul des filtres, leur ordre et leur rendu ne lisent que l'index, et l'index ne connaît que des champs. D'où le CatalogScope : tout ce qu'il faut savoir d'un périmètre pour le filtrer, et rien de plus.
| Propriété | Rôle |
|---|---|
filters | ce qui restreint le catalogue, en filtres du moteur : ['category_id' => 3], ['brand_id' => 7] |
url | la page du périmètre — c'est sur elle qu'une colonne de filtres poste, un formulaire GET remplaçant la query string de la page où il se trouve |
browsing | le nœud (Category) dont on propose les enfants ; la racine quand le périmètre n'est pas un rayon — une marque propose alors les univers où elle vend |
subject | ce que le périmètre restreint (`Model |
countUrl | l'adresse qui compte les produits d'une combinaison avant qu'on la valide ; null quand le périmètre n'en propose pas — le comptage à la volée est un service, pas un dû |
search | ce que le visiteur a tapé, quand le périmètre est une recherche ; une source de filtres peut en tenir compte — aucune de celles du cœur ne le fait |
CatalogScope::forCategory() construit celui d'une catégorie : elle se restreint à elle-même (category_id), propose ses enfants, se désigne comme subject et pointe sa route de comptage. CategoryService::scope() le rend, construit une fois pour la page.
CatalogScope::forSearch() construit celui d'une recherche : les termes tapés, et rien d'autre. Pas de filtre, pas d'adresse — le panneau de l'entête rend ses résultats sur place, et taper Entrée ne mène nulle part —, donc pas de comptage à la volée ; et la racine comme nœud parcouru : une recherche propose les univers où elle a trouvé, comme une marque propose ceux où elle vend.
CatalogScope::availableFilters() est ce qui lit l'index — et mémorise : une page relit ses filtres plusieurs fois (la colonne, son rendu, le comptage), et chaque lecture serait sinon une requête. proposing($available) rend une copie dont les valeurs sont déjà connues — un cache, un service tiers, un double de test — sans interroger le moteur ; une copie, et non une mutation : un scope dit ce qu'il propose, il n'en change pas en cours de page.
Un panneau compte sur ce qu'il montre
Les valeurs d'un périmètre de recherche ne se lisent pas dans l'index : la distribution du moteur porte sur tout le périmètre de la requête, et proposerait des valeurs qu'aucun des résultats affichés ne porte — un filtre qui ne masque rien, ou qui masque tout. CatalogFacets::fromDocuments($documents, $facets) compte donc les facettes sur les documents déjà en main — les champs qui sont des listes de clés ou des scalaires, le reste étant ignoré, faute de savoir ce qu'on en compterait ; les valeurs numériques y donnent leurs bornes comme chez le moteur. Le registre en tire les filtres (build()), et proposing() les pose sur le scope : aucune requête ne part.
Reste à savoir dans quel champ du document vérifier la valeur qu'on vient de cocher : CatalogFilterRegistry::facetsOf($key) rend les facettes qui nourrissent une source, là où facets() les rend toutes en bloc. C'est ce qui permet à un écran de filtrer ce qu'il affiche sans repartir au serveur.
php
use Slab\Framework\Core\Catalog\CatalogScope;
$scope = new CatalogScope(
filters: ['brand_id' => $brand->id],
url: $brand->url,
browsing: $root, // la marque propose les univers où elle vend
subject: $brand,
);Le référentiel de filtres, le presenter et les vues du thème consomment ce scope sans rien savoir de la marque : un package qui ajoute un périmètre n'a donc rien à redéfinir.
Un scope a l'âge de sa page
Ce qu'il mémorise a l'âge de l'index au moment où il l'a lu : ne pas le garder au-delà d'une page.
Les filtres d'une catégorie
Les filtres qu'un périmètre propose sont déclarés au CatalogFilterRegistry, qui ouvre deux portes sur une même clé : add() — comment calculer les valeurs disponibles — et view(), comment les rendre, posée par le thème. Un package de marques n'a donc ni à remplacer le service de catégorie, ni à posséder le thème ; et un thème peut ignorer un filtre du cœur (calculé, non rendu — viewFor() rend alors null) ou en redéfinir le rendu sans toucher au calcul.
Les valeurs viennent de l'index, jamais d'un précalcul. CatalogScope::availableFilters() demande au registre les facettes dont ses sources ont besoin (facets(), dédoublonnées), les réclame au moteur en une requête (ProductIndexService::facetsFor($filters, $facets)), et passe le CatalogFacets obtenu à build($facets, $scope) — CategoryService::availableFilters() n'est plus que le relais de son périmètre. Une source ne reçoit donc pas les produits de la branche mais ce que le moteur sait d'eux : les valeurs présentes derrière chaque facette (keys(), values() avec les comptes, keysOfAny() pour en réunir plusieurs) et les bornes des facettes numériques (bounds(), null si la facette est absente ou plate). Ce qui était refait en PHP à chaque indexation — tous les produits de la branche rechargés, leur document rebâti, le résultat figé — coûtait 362 requêtes et 894 ms pour une catégorie de neuf produits, contre une requête et 12 ms de ce côté-ci.
Le cœur inscrit quatre sources (CoreCatalogFilters), par la même porte qu'un package :
| Clé | Priorité | Facette lue | Ce qu'il propose |
|---|---|---|---|
categories | 5 | categories | les enfants immédiats du nœud parcouru ($scope->browsing) présents dans la facette (triés par leur position dans l'arbre), en traductions |
price | 10 | prices | la fourchette de prix du périmètre |
attributes | 20 | attribute_values + feature_values | les valeurs d'attributs présentes — celles qui déclinent un produit et celles qui le décrivent, ensemble —, groupées par attribut et traduites |
availability | 30 | availability | les états de disponibilité présents, en clés, les états du cœur d'abord ; null en dessous de deux états, un périmètre entièrement en stock n'ayant rien à filtrer |
Attributs et caractéristiques ne font qu'un filtre parce que le référentiel est unique : ce qui sépare les deux champs du document, c'est l'usage qu'un produit fait de la valeur, pas la nature de l'attribut. Les séparer donnait deux filtres homonymes dès qu'un attribut servait aux deux — « Tension » pour la clé à choc qui s'y décline, « Tension » pour la perceuse qui la porte —, et chacun était une impasse pour l'autre produit. Le visiteur, lui, n'a qu'une question en tête.
Comme le document indexe un produit sous ses catégories et leurs ancêtres, une sous-catégorie qui ne porte rien en propre mais dont la descendance est peuplée figure quand même dans categories : « Outillage électroportatif » se propose depuis « Outillage » même si les perceuses sont deux niveaux plus bas.
php
use Slab\Framework\Core\Catalog\CatalogFacets;
use Slab\Framework\Core\Catalog\CatalogScope;
use Slab\Framework\Core\Catalog\Contracts\CatalogFilterRegistry;
$registry = $this->app->make(CatalogFilterRegistry::class);
$registry->add('brands', function (CatalogFacets $facets, CatalogScope $scope): ?array {
$ids = $facets->keys('brand_id');
return $ids === [] ? null : Brand::findMany($ids)->pluck('name', 'id')->all();
}, priority: 40, facets: ['brand_id'], field: 'brand_id');
// Côté thème :
$registry->view('brands', 'theme::catalog.filters.brands');Le champ nourricier (brand_id ici) doit être connu du SearchIndexRegistry — en facette pour être compté, en filtre pour être appliqué. facets accepte aussi une closure, quand la liste dépend des données : les bornes d'un attribut numérique se demandent sous un chemin qui porte son identifiant, inconnu à la déclaration. Et field sert quand le champ porté par l'URL diffère de la clé de la source — ici, brands se calcule sous sa clé et se filtre sous brand_id ; sans ce lien, une vue devrait le deviner, et le filtre partirait sous un nom que le moteur ignore, sans rien dire. À défaut, fieldFor() rend la clé.
Cocher une sous-catégorie restreint la page
Les sous-catégories du cœur gardent leur clé comme champ : elles se filtrent sous categories, quand la catégorie de la page, elle, est posée sous category_id par le périmètre (CatalogScope::forCategory()). Deux clés, deux expressions que le moteur cumule — cocher « Outillage à main » depuis « Outillage » restreint la page au lieu de la remplacer, et une sous-catégorie forgée dans l'URL ne peut plus faire sortir de la branche affichée. Un category_id forgé, lui, n'a pas cette garde : getProducts() le laisse remplacer celui de la page.
Le moteur ne rend que des clés brutes : traduire est l'affaire de chaque source, qui charge les libellés en une requête (CoreCatalogFilters::attributes() fait un findMany sur les valeurs d'attributs et rend leurs getTranslations('name')). Le builder rend null — un tableau vide ou false valent refus — quand le filtre n'a rien à proposer : il est alors écarté. C'est ce que fait le filtre price du cœur quand tous les produits sont au même prix : un curseur aux bornes égales n'a rien à filtrer, et bounds() rend null de lui-même. Réenregistrer une clé remplace la précédente — c'est ainsi qu'un projet requalifie un filtre du cœur, la priority fixant l'ordre de calcul (croissant). Les calculs de CoreCatalogFilters étant des méthodes publiques, une clé requalifiée peut composer avec celui qu'elle remplace (CoreCatalogFilters::attributes($facets, $scope)) au lieu de le réécrire — en redéclarant les mêmes facettes, que la constante CoreCatalogFilters::ATTRIBUTE_FACETS tient à disposition.
La branche suit toute seule
Une catégorie calcule ses filtres sur sa branche sans rien faire pour cela : le document indexe un produit sous ses catégories et leurs ancêtres, si bien que le seul filtre category_id couvre déjà la descendance. Rattacher un produit à « Perceuses » change donc ce que proposent « Outillage électroportatif » et « Outillage » — sans recalcul à propager, puisqu'il n'y a plus rien de figé.
Du calcul au rendu
Le registre dit ce qu'on peut proposer. Ce qu'on veut proposer — quels filtres, sous quel nom, dans quel ordre, sous quelle forme — relève du CatalogFilterPresenter : for(CatalogScope) rend une Collection de PresentedFilter, et c'est ce contrat que le front consomme, jamais la liste brute des valeurs. Une vue n'a pas à savoir si un référentiel est installé — ni de quel périmètre elle rend les filtres.
DefaultCatalogFilterPresenter livre l'ordre par défaut — celui du registre, tout ce que le périmètre a de quoi proposer ($scope->availableFilters()) — et nomme ce que le cœur déclare (Sous-catégories, Prix, Disponibilité ; ucfirst de la clé sinon). Il éclate attributes en un filtre par attribut, l'identifiant de l'attribut passant en parameter : le visiteur n'a pas à démêler un bloc unique. La forme, il la déduit de ce que la source a rendu : un tableau qui porte min et max demande une comparaison (RANGE, les bornes passant en range), tout le reste se coche (VALUES) — un attribut, lui, se coche toujours, car trancher pour des bornes demande un réglage que le cœur n'a pas. Un package qui administre les filtres substitue ce service ; c'est lui qui décide alors du nom, de l'ordre, de la forme et de ce qu'on éteint.
PresentedFilter est volontairement neutre : key (la source, celle qui porte la vue), field (le champ de l'URL), parameter, label déjà traduit, kind, values ou range, display et view. Deux méthodes servent le formulaire : name() rend le nom du champ (price, attributes[5]), et selectedIn($selected) extrait ce que le visiteur a déjà retenu — une valeur seule reste une valeur, la query string ne l'emballe pas toujours et plusieurs peuvent arriver séparées par des virgules.
Deux formes, pas trois
CatalogFilterKind ne connaît que VALUES et RANGE, parce que c'est ce que le moteur sait faire d'un champ : une appartenance, ou une comparaison. Comment on le demande à l'écran — cases, boutons, nuancier, curseur — se range dans display, chaîne libre : le cœur ne fige pas le vocabulaire d'un thème.
Voir aussi
- Les pipelines — le calcul de prix.
- Modèles & relations · Pricing & taxe.
- Points d'extension — la liste complète.