Skip to content

Les registres ​

Un registre laisse un package ajouter un élément à une liste que le cœur assemble — sans que le cœur connaisse cet élément. C'est la mécanique des menus, des routes, des étapes de checkout, des options et des hooks.

Le problème ​

Un package « promotions » veut une entrée dans le menu du back-office et ses propres routes d'administration. Mais le cœur ne doit jamais référencer « promotions ». Comment ajouter à une liste possédée par le cœur, de l'extérieur ?

Le principe ​

Le cœur expose un registre (un singleton). Au démarrage, chaque package y dépose sa contribution. Le cœur lit la liste agrégée au moment voulu (rendu du menu, montage des routes…).

Tout se passe dans le boot() du provider.

Exemple : une entrée de menu ​

php
use Slab\Framework\Core\BackMenu\Contracts\BackMenuRegistry;

$this->app->make(BackMenuRegistry::class)->add(
    'catalog.promotions', __('Promotions'),
    route: 'back.promotions.index', icon: 'local_offer', parent: 'catalog', priority: 40,
);

Une entrée sans route est un simple groupe : elle n'existe que pour accueillir les entrées filles qui se rattachent à elle par parent. Un parent nul place l'entrée au premier niveau.

Réordonner une entrée qu'on ne possède pas se fait par prioritize() : la redéclarer obligerait à recopier son libellé, sa route et son icône — donc à les figer, et à les voir cesser de suivre le package qui les possède. Une clé inconnue est ignorée, un package désinstallé n'empêche pas le menu de se construire.

php
$this->app->make(BackMenuRegistry::class)->prioritize('catalog.promotions', 10);

Exemple : des routes ​

php
use Slab\Framework\Core\Routing\Contracts\RouteContributionRegistrar;
use Illuminate\Support\Facades\Route;

$this->app->make(RouteContributionRegistrar::class)->back(function () {
    Route::resource('promotions', PromotionsController::class)->except('show', 'destroy');
});

Les routes contribuées héritent du groupe back-office (préfixe admin/, noms back.*, middlewares auth + CanAccessBackoffice) et sont montées dans le bon ordre vis-à-vis des routes attrape-tout du front. Le pendant front est ->front(...) : son callback s'exécute dans le groupe front localisé, avant les routes de slug, de sorte que les routes contribuées priment. Comme pour les autres registres, la déclaration a lieu au boot() — donc avant le chargement des fichiers de routes, et les contributions sont capturées par route:cache.

Les registres de Slab ​

RegistreVous y ajoutez…Détail
BackMenuRegistryune entrée de menu BOpriorité réordonnable par prioritize() ou config/back_menu.php
RouteContributionRegistrardes routes back() / front()ordre garanti
CheckoutStepRegistryune étape de tunnelporté par le paquet frontend ; priorité via config/checkout.php
CheckoutOptionRegistryun fournisseur d'options (livraison, paiement…)voir ci-dessous
ViewHookRegistryun onglet / une section dans une vuevoir hooks & slots
FormLayoutRegistryun onglet / une section à la charpente d'un écran BOmoveSection() déplace une section du cœur, aside: true la range dans la colonne étroite ; le paramètre columns de tab() est déprécié et sans effet ; voir hooks & slots
DatatableExtensionRegistryune colonne / action à une datatable existantevoir ci-dessous
BulkActionRegistryune action sur les lignes cochées d'une listevoir hooks & slots
PriceModifierRegistry, TotalsModifierRegistryun maillon de pipelinepriorités
ModelExtensionRegistryune colonne / un cast / un contenu à un modèle du cœurvoir modèles
FormRuleRegistrydes règles à une Form Request du cœurfusionnées à la validation
SaveHookRegistryce qu'un enregistrement écrit au-delà des colonnes (relations, prix, défaut)voir ci-dessous
AccessControlRegistryun rôle / une permissionconsommé par le seed et les écrans
SearchIndexRegistryun attribut indexé, une facette, un filtre, un trivoir ci-dessous
CatalogFilterRegistryun filtre de catalogue (calcul et rendu)voir ci-dessous
ProductStatusRegistryun statut de produit (« fin de série »)voir ci-dessous
OrderMailRegistryun e-mail transactionnel proposable sur un statutvoir ci-dessous
IndicatorRegistryun chiffre ou un classement de la boutiquevoir ci-dessous
MenuIconRegistryune icône proposable sur une entrée de menuvoir ci-dessous

Cas particulier : les options de checkout ​

Une feature qui ajoute un choix au tunnel (un transporteur, un mode de paiement) n'expose que de la donnée via un CheckoutOptionProvider — jamais de HTML :

php
use Slab\Framework\Core\Checkout\Contracts\CheckoutOptionRegistry;

$this->app->make(CheckoutOptionRegistry::class)->register(ShippingOptionProvider::class);

Le provider nomme l'exigence qu'il couvre (requirement() — shipping, payment…) et rend, pour un panier, les options disponibles (options(Cart $cart)) : des CheckoutOption structurées (id, libellé, surcoût HT/TTC, métadonnées, et au besoin un sous-formulaire déclaratif ou un slot de vue). Le cœur agrège — for($cart) rend les options regroupées par exigence — et le frontend les rend génériquement : ajouter un transporteur le fait apparaître au checkout, sans toucher au front.

for($cart) ne rend une exigence que si elle a au moins une option pour ce panier. Savoir si l'étape existe est une autre question : offers($requirement) y répond, vrai dès qu'un provider enregistré couvre l'exigence, quel que soit le panier. Sans paquet de livraison, il n'y a pas d'étape de livraison ; avec un paquet, l'étape existe même quand aucune de ses options ne dessert le panier — elle doit alors le dire, et non disparaître. Détail : Commande & checkout.

Étendre une datatable existante ​

Les listings du back-office sont des datatables identifiées par un id (products, categories, currencies…). Le DatatableExtensionRegistry laisse un package ou votre application ajouter une colonne, une action ou une option à une datatable existante, sans la forker :

php
use Slab\Framework\Core\View\Contracts\DatatableExtensionRegistry;
use Stafe\LaravelDatatable\Core\Datatable;

$this->app->make(DatatableExtensionRegistry::class)->extend('products', function (Datatable $datatable) {
    $datatable->columns()->column('supplier')->label(__('Fournisseur'));
});

La contribution reçoit la Datatable après sa construction et la mute via l'API publique du paquet datatable (colonnes, actions, options) ; les contributions d'un même id s'appliquent dans l'ordre d'enregistrement. Les ids sont ceux que les datatables du cœur déclarent (Datatable::getId()).

Agir sur les lignes cochées d'une liste ​

Le DatatableExtensionRegistry ajoute une colonne ; le BulkActionRegistry ajoute un geste. Une action groupée se déclare sous l'id du listing, avec son libellé, sa confirmation, le droit qu'elle exige et ce qu'elle fait :

php
use Illuminate\Database\Eloquent\Collection;
use Slab\Framework\Core\View\BulkAction;
use Slab\Framework\Core\View\Contracts\BulkActionRegistry;
use Slab\Framework\Models\Product;

$this->app->make(BulkActionRegistry::class)->add('products',
    BulkAction::make('reindex', Product::class)
        ->label(fn (): string => __('Réindexer'))
        ->handle(function (Collection $products): void {
            $products->each->searchable();
        }));

Une route unique les exécute toutes : elle résout l'action par sa clé, revérifie le droit (allowedFor() ne rend que celles que l'utilisateur courant peut exécuter), charge les lignes qui existent encore et rend compte de ce qui a été fait — et de ce qui ne l'a pas été. Sans retour explicite, le cœur considère que toutes les lignes reçues ont été traitées ; pour dire autre chose, l'exécution rend un BulkActionReport. Une clé déjà enregistrée est remplacée, ce qui permet à un projet de redéfinir une action du cœur sans la supprimer. Un listing sans action déclarée n'affiche simplement pas de barre d'actions. Le détail des règles est dans hooks & slots.

Écrire au-delà des colonnes : le SaveHookRegistry ​

Enregistrer un produit ne se limite pas à ses colonnes. Il faut aussi synchroniser ses catégories, historiser son prix et recopier sur ses déclinaisons ce qui a été coché. Si ce travail est fait dans le contrôleur, un enregistrement qui passe par le SavingService sans passer par l'écran ne l'exécute pas : une IA n'écrirait donc pas la même chose que la fiche.

Slab\Framework\Core\Saving\Contracts\SaveHookRegistry règle ce problème. On y déclare ce travail une fois par type d'objet, et le SavingService l'exécute à chaque enregistrement, quel que soit l'appelant. Ces hooks n'ont rien à voir avec les hooks de vue.

Le principe ​

Un hook s'exécute à l'un de ces deux moments :

  • avant, déclaré par saving() : le hook reçoit le modèle et les valeurs, puis rend les valeurs, complétées ou ajustées ;
  • après, déclaré par saved() : l'objet a sa clé et le hook écrit ce qui lui revient parmi les valeurs. Il ne rend rien.

DefaultSavingService::save() fait passer les valeurs par chaque hook d'avant et les pose avec fill(). Il annonce ensuite l'enregistrement par ModelSaving : un écouteur voit donc les valeurs déjà complétées. Enfin, il enregistre l'objet et transmet les mêmes valeurs aux hooks d'après. Si l'enregistrement est refusé, aucun hook d'après ne s'exécute.

Un hook se déclare pour une classe de modèle et vaut aussi pour ses sous-classes. Ce peut être une closure (Model, array) ou le nom d'une classe invocable. Le conteneur ne résout cette classe qu'au moment de l'exécuter, et son constructeur reçoit donc ses dépendances. Les hooks d'un même moment s'exécutent par priorité croissante (100 par défaut). À priorité égale, l'ordre de déclaration départage.

Côté lecture, beforeSaving($model) et afterSaving($model) rendent les hooks qui s'appliquent à l'objet, déjà triés et résolus. C'est ce que lit le SavingService. Si vous substituez ce service, votre version doit les exécuter aussi : sans eux, les catégories et le prix d'un produit ne s'écrivent plus.

Ce que le cœur déclare ​

Dans le boot() du CoreServiceProvider :

php
$hooks->saving(Category::class, PlaceUnderRoot::class);

$hooks->saved(Product::class, SyncProductCategories::class, priority: 10);
$hooks->saved(Product::class, RecordProductPrice::class, priority: 20);
$hooks->saved(Product::class, ApplyToVariants::class, priority: 900);
HookMomentCe qu'il écrit
PlaceUnderRootavantrange une catégorie sans parent sous la racine de l'arbre. Il agit avant l'enregistrement parce que l'arbre imbriqué place le nœud dès que son parent est posé
SyncProductCategoriesaprès, 10categories et main_category_id ; la catégorie par défaut rejoint les catégories du produit
RecordProductPriceaprès, 20price_te, sans écraser l'historique : un prix nouveau s'ajoute, un prix inchangé n'écrit rien
ApplyToVariantsaprès, 900« Appliquer aux déclinaisons », une fois les catégories et le prix écrits

Un hook de paquet déclaré à la priorité par défaut s'exécute donc après les catégories et le prix d'un produit, et avant la recopie sur ses déclinaisons.

Déclarer le sien ​

Prenons un paquet qui ajoute à la fiche produit une liste de fournisseurs. Il a besoin de trois choses : la relation, la règle de validation et le hook. La règle est indispensable, car le SavingService ne reçoit que les valeurs validées ($request->validated()). Sans règle, suppliers n'arrive jamais jusqu'au hook.

php
use Acme\Sourcing\Models\Supplier;
use Acme\Sourcing\Saving\SyncProductSuppliers;
use Slab\Framework\Core\Saving\Contracts\SaveHookRegistry;
use Slab\Framework\Core\Validation\Contracts\FormRuleRegistry;
use Slab\Framework\Http\Requests\Back\SaveProductRequest;
use Slab\Framework\Models\Product;

public function boot(): void
{
    Product::resolveRelationUsing('suppliers',
        fn (Product $product) => $product->belongsToMany(Supplier::class, 'acme_product_supplier'));

    $this->app->make(FormRuleRegistry::class)->extend(SaveProductRequest::class, [
        'suppliers' => ['nullable', 'array'],
        'suppliers.*' => ['integer', 'exists:acme_suppliers,id'],
    ]);

    $this->app->make(SaveHookRegistry::class)->saved(Product::class, SyncProductSuppliers::class);
}

La relation se greffe comme indiqué dans Modèles & relations. Le hook, lui, suit le modèle de SyncProductCategories :

php
namespace Acme\Sourcing\Saving;

use Slab\Framework\Models\Product;

final class SyncProductSuppliers
{
    /**
     * @param  array<string, mixed>  $values
     */
    public function __invoke(Product $product, array $values): void
    {
        // Absente, la liste ne touche à rien : l'enregistrement peut être partiel.
        if (! array_key_exists('suppliers', $values)) {
            return;
        }

        $product->suppliers()->sync(array_map('intval', (array) ($values['suppliers'] ?? [])));
    }
}

Un hook d'avant suit la même forme, avec une différence : il rend toujours $values, même inchangées, car le service remplace les valeurs par ce que le hook retourne.

Une valeur absente ne touche à rien

Le SavingService enregistre aussi des modifications partielles, par exemple une IA qui ne change que le statut ou une action groupée. Si un hook traite l'absence d'une clé comme une liste vide, il efface ce que personne n'a demandé d'effacer. Testez array_key_exists(), jamais empty(). Pour qu'une liste entièrement décochée arrive vide, le formulaire doit la poster ainsi. Le composant <x-form.fields.select> s'en charge pour une liste multiple dont le nom finit par [], et l'arbre à cases (<x-tree with-checkboxes>) aussi : un champ caché vide précède la liste, et c'est lui qui est envoyé quand rien n'est choisi. Un formulaire écrit à la main fait de même :

blade
<input type="hidden" name="suppliers" value="">
<select name="suppliers[]" multiple>…</select>

Pas de transaction

Le DefaultSavingService n'ouvre pas de transaction. Si un hook d'après échoue, l'objet reste enregistré. Un appelant qui ouvre sa propre transaction doit faire en sorte que les effets de bord mis en file attendent qu'elle soit validée (voir les jobs et les transactions dans la doc Laravel).

Refuser un enregistrement ne passe pas par ce registre : on écoute ModelSaving. La liste complète des contrats se trouve dans les points d'extension.

Rendre un champ cherchable et filtrable ​

Greffer un champ sur un modèle ne suffit pas à le rendre cherchable : le moteur doit savoir qu'il existe, ce qu'il peut en faire, et comment traduire un filtre de l'URL. C'est le rôle du SearchIndexRegistry, où le cœur déclare ses attributs comme le ferait n'importe quel package.

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

$search = $this->app->make(SearchIndexRegistry::class);

// 1. Le champ entre dans le document indexé, et devient filtrable + facette.
$search->attribute('brand', fn (Product $p): ?string => $p->brand?->slug, filterable: true, facet: true);

// 2. Le paramètre d'URL `?brand=acme` devient une expression du moteur.
$search->filter('brand', fn (mixed $value): string => 'brand = "'.$value.'"');

// 3. Optionnel : un tri `?sort=brand:asc`.
$search->sort('brand', fn (string $way): string => 'brand:'.$way);

Quatre drapeaux disent ce que le moteur a le droit de faire du champ : searchable, filterable, sortable, facet. Le resolver, lui, est facultatif — un attribut que le modèle produit déjà, comme ceux du cœur, se déclare sans, pour ses seuls drapeaux.

Les listes searchableAttributes / filterableAttributes / sortableAttributes de Meilisearch en découlent : elles sont posées au boot dans config('scout.meilisearch.index-settings'), puis poussées par scout:sync-index-settings. Un handler de filtre peut rendre plusieurs expressions — elles s'ajoutent toutes à la requête. Un filtre dont la clé est inconnue du registre est ignoré : un paramètre d'URL inattendu ne fait pas échouer la recherche.

Déclarer un filtre écarte aussi ses adresses des robots : filterKeys() rend les clés de filtre déclarées, et le robots.txt du cœur pose pour chacune Disallow: /*?clé et Disallow: /*&clé — le brand ci-dessus compris, sans rien à câbler, comme le paramètre de tri (CatalogSort::PARAMETER). Chaque combinaison de filtres est une adresse de plus qui ne montre qu'une partie du rayon ; un robot qui ne les parcourt pas garde ses passages pour les pages qui comptent. La pagination, elle, reste ouverte : /*?price ne bloque pas ?p=2.

Ajouter un filtre au catalogue ​

Un filtre de catalogue a deux moitiés : calculer les valeurs disponibles, et les afficher. Le CatalogFilterRegistry les réunit sous une même clé — le package apporte le calcul, le thème apporte la vue.

Le calcul ne reçoit pas les produits mais ce que le moteur en sait : chaque source déclare la ou les facettes qui la nourrissent, le service les réclame en une requête, et lui passe le CatalogFacets obtenu.

Il reçoit aussi le périmètre — un CatalogScope : ce qui restreint la vitrine (['category_id' => 3], ['brand_id' => 7]), la page sur laquelle la colonne de filtres poste, et le nœud dont on propose les enfants. Une source ne sait donc pas ce qui restreint le catalogue, et n'a pas à le savoir : c'est ce qui lui permet de servir une page catégorie comme une page marque.

php
use Slab\Framework\Core\Catalog\CatalogFacets;
use Slab\Framework\Core\Catalog\CatalogScope;
use Slab\Framework\Core\Catalog\Contracts\CatalogFilterRegistry;

$filters = $this->app->make(CatalogFilterRegistry::class);

// Côté package : traduire les clés que le moteur rend (null = pas de filtre à afficher).
$filters->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 : comment le rendre (la vue reçoit $filter, $selected, $scope).
$filters->view('brands', 'mon-theme::filters.brands');

CatalogFacets ne rend que des clés brutes : les valeurs présentes derrière une 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). Les traduire est l'affaire de la source — un libellé indexé figerait la langue.

Ajouter un périmètre ​

Un package qui ouvre une nouvelle façon de découper le catalogue — une marque, un fournisseur, une recherche — construit son CatalogScope et réutilise tout le reste : le référentiel, la colonne de filtres du thème et les vues des filtres, sans en redéfinir aucune.

php
$scope = new CatalogScope(
    filters: ['brand_id' => $brand->id],     // ce qui restreint, en filtres du moteur
    url: $brand->url,                        // là où la colonne de filtres poste
    browsing: Category::getRoot(),           // le nœud dont on propose les enfants
    subject: $brand,                         // pour qui a besoin de reconnaître le périmètre
    countUrl: route('front.brands.ajax.filters-count', $brand),  // ou null
);

$scope->availableFilters();   // ce que le périmètre a de quoi proposer, lu dans l'index

browsing mérite un mot : c'est le nœud dont le filtre « sous-catégories » propose les enfants. La catégorie elle-même quand le périmètre en est une, la racine sinon — une marque propose alors les univers où elle vend.

Une recherche en est un aussi, et le cœur la construit : CatalogScope::forSearch($terms) ouvre un périmètre sans page (url vide) ni comptage à la volée, dont le nœud parcouru est la racine et qui porte dans search ce que le visiteur a tapé — une source de filtres peut en tenir compte, aucune de celles du cœur ne le fait.

Les filtres d'un panneau de recherche ne se lisent pas dans l'index

Un panneau filtre ce qu'il affiche, sans repartir au serveur : les valeurs proposées doivent être celles des produits rendus, sinon on coche et tout disparaît. CatalogFacets::fromDocuments() les compte sur les documents en main, et proposing() passe les filtres ainsi calculés au périmètre, qui n'interroge alors personne.

php
$facets = CatalogFacets::fromDocuments($documents, $registry->facets());

$scope = CatalogScope::forSearch($terms);
$scope = $scope->proposing($registry->build($facets, $scope));

Le champ nourricier doit être connu du SearchIndexRegistry — en facette pour être compté, en filtre pour être appliqué ; le registre dédoublonne d'ailleurs les facettes réclamées (facets()) pour n'interroger le moteur qu'une fois, et facetsOf() rend celles d'une source : c'est ce qui dit à un écran dans quel champ du document chercher la valeur qu'on vient de cocher. facets accepte aussi une closure, quand la liste dépend des données ; et field sert quand le champ porté par l'URL diffère de la clé de la source (fieldFor() rend la clé à défaut) — les sous-catégories se calculent sous categories mais se filtrent sous category_id.

Rien n'est figé : $category->filters interroge l'index à la demande — le résultat est mémorisé sur l'instance, il n'y a plus de colonne ni de réindexation à attendre. Un filtre calculé sans vue déclarée n'est simplement pas affiché (viewFor() rend null) : c'est ainsi qu'un thème peut en ignorer un. Détail : Catalogue.

Déclarer un statut de produit ​

Tout catalogue finit par vouloir plus que « brouillon » et « en ligne » : sur commande, fin de série, précommande. Un enum PHP l'interdirait — il est fermé. ProductStatus est donc un objet déposé au ProductStatusRegistry, et ses effets sont déclarés plutôt que codés en dur dans les requêtes du cœur.

php
use Slab\Framework\Core\Product\Contracts\ProductStatusRegistry;
use Slab\Framework\Core\Product\ProductStatus;

$this->app->make(ProductStatusRegistry::class)->add(new ProductStatus(
    'discontinued',
    fn (): string => __('Fin de série'),
    visible: true,     // le produit s'affiche en front
    orderable: false,  // il ne s'achète plus
    priority: 30,      // ordre dans les listes de choix (croissant)
));

Une déclaration, honorée partout : le cœur ne compare jamais à « en ligne », il interroge le registre.

Le cœur lit……où
visibleKeys()le scope Product::visible() — fiche, catalogue, indexation
orderableKeys()le scope Product::orderable() ; $product->is_orderable y ajoute la disponibilité
list()le select de la fiche produit et la datatable products (filtre + édition inline)
all()la validation de SaveProductRequest (Rule::in sur les clés déclarées)
default(), get()ProductStatusCast, qui traduit la colonne status en objet et retour

La colonne status reste une chaîne : un statut ajouté ne demande aucune migration. Une clé inconnue — le paquet qui la déclarait 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.

add() indexe par clé, donc une clé déjà enregistrée est remplacée — c'est ainsi qu'un projet change les effets d'un statut du cœur sans le supprimer. Ici, une boutique catalogue, qui expose sans vendre :

php
use Slab\Framework\Core\Product\CoreProductStatuses;

$registry = $this->app->make(ProductStatusRegistry::class);

$registry->add(new ProductStatus(
    CoreProductStatuses::ONLINE,
    fn (): string => __('En ligne'),
    visible: true,
    orderable: false, // le produit s'affiche, il ne s'achète pas
    priority: 20,
));

Le statut d'un produit qui n'en choisit pas se désigne par setDefault('…').

Un libellé en fermeture, pas une chaîne

Comme pour les autres registres, la déclaration a lieu au boot des providers, avant que la locale de la requête ne soit fixée : un __() évalué là servirait la langue par défaut à tout le monde. La fermeture, elle, n'est appelée qu'à l'affichage. ProductStatus accepte les deux formes.

Le statut est une décision éditoriale ; ce que le stock permet d'en dire est la disponibilité, un contrat à part — l'opposition est détaillée dans Catalogue.

Proposer un e-mail transactionnel ​

Les statuts de commande peuvent déclencher un e-mail, choisi dans une liste au back-office. Un package y inscrit le sien :

php
use Slab\Framework\Core\Order\Contracts\OrderMailRegistry;

$this->app->make(OrderMailRegistry::class)->add(ShippingMail::class);

Sans libellé explicite — add() en accepte un en second argument —, celui d'un OrderStatusMail est son sujet ; à défaut, le nom court de la classe. all() rend les mailables classés par libellé, et c'est cette liste que l'écran des statuts propose ; le statut retenu déclenche l'envoi au changement (OrderStatusChanged → SendOrderStatusMail).

Déclarer un indicateur ​

Le cœur déclare ses chiffres — chiffre d'affaires, panier moyen, meilleures ventes — dans l'IndicatorRegistry, pour que tout ce qui les affiche dise les mêmes ; ce qu'ils comptent et comment les lire est dans Commande & checkout. Un package y déclare les siens de la même façon : un Measure pour un chiffre sur une période et sa série, un Ranking pour un classement — jamais un Indicator seul.

Un package qui gère les annulations veut suivre les commandes annulées. C'est un nombre sur une période, donc un Measure :

php
namespace Acme\Cancellations\Indicators;

use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Slab\Framework\Core\Indicator\Contracts\Measure;
use Slab\Framework\Core\Indicator\Interval;
use Slab\Framework\Core\Indicator\MeasureUnit;
use Slab\Framework\Core\Indicator\MeasureValue;
use Slab\Framework\Core\Indicator\Period;
use Slab\Framework\Core\Indicator\SeriesPoint;
use Slab\Framework\Core\Time\ShopTime;
use Slab\Framework\Models\Order;

final class CancelledOrders implements Measure
{
    public function key(): string
    {
        return 'cancelled_orders';
    }

    public function label(): string
    {
        return __('Commandes annulées');
    }

    public function description(): string
    {
        return __('Le nombre de commandes annulées sur la période, validées ou non, datées du jour de leur annulation.');
    }

    public function unit(): MeasureUnit
    {
        return MeasureUnit::Count;
    }

    public function measure(Period $period): MeasureValue
    {
        return MeasureValue::count($this->cancelled($period)->count());
    }

    public function series(Period $period, Interval $interval): array
    {
        $query = $this->cancelled($period)->toBase();
        // Rangées par jours de la boutique, et non de la base.
        $date = ShopTime::column($query->getConnection(), $query->getGrammar()->wrap('orders.cancelled_at'), $period->from, $period->until);
        $bucket = $interval->expression($query->getConnection(), $date);

        $counts = $query->selectRaw("{$bucket} as bucket, COUNT(*) as aggregate")
            ->groupByRaw($bucket)
            ->pluck('aggregate', 'bucket')
            ->all();

        return array_map(
            fn (CarbonImmutable $start): SeriesPoint => new SeriesPoint(
                $start,
                MeasureValue::count((int) ($counts[$start->toDateString()] ?? 0)),
            ),
            $interval->starts($period),
        );
    }

    /**
     * @return Builder<Order>
     */
    private function cancelled(Period $period): Builder
    {
        return Order::query()->whereBetween('orders.cancelled_at', [$period->from, $period->until]);
    }
}
  • description() dit ce qui est compté, et comment — quelles commandes, à quelle date : c'est ce qui permet de lire le chiffre sans se tromper.
  • label() et description() appellent __() directement, sans fermeture : ce sont des méthodes de l'indicateur, que personne n'appelle à la déclaration — add() ne lit que la clé. Elles s'exécutent à la lecture, dans la langue de la requête.
  • La série se construit en deux temps, qui se rejoignent sur la même clé — la date de début de l'intervalle, en AAAA-MM-JJ : la base regroupe par Interval::expression(), le code complète les intervalles vides par Interval::starts(). expression() attend une colonne déjà protégée (d'où le wrap()) et sait écrire les dates de SQLite, MySQL/MariaDB et PostgreSQL ; sur une autre base, elle lève une LogicException. NewCustomers, dans le cœur, est écrit ainsi.
  • Les jours sont ceux de la boutique : les bornes d'une Period tombent à minuit dans son fuseau (shop.timezone), mais la base date en UTC. Avant de regrouper, ShopTime::column() ramène donc la colonne à l'heure de la boutique — sans lui, une annulation passée à 0 h 30 à Paris tomberait dans le jour d'avant. Il reçoit les bornes de la période parce que le décalage change avec l'heure d'été : chaque changement d'heure du laps ouvre son propre décalage. Il attend lui aussi une colonne déjà protégée, connaît les mêmes trois bases, et lève une LogicException sur une autre.

Puis, dans le boot() du provider du package :

php
use Acme\Cancellations\Indicators\CancelledOrders;
use Slab\Framework\Core\Indicator\Contracts\IndicatorRegistry;

$this->app->make(IndicatorRegistry::class)->add(CancelledOrders::class);

add() accepte une instance ou une classe : une classe est résolue par le conteneur, qui injecte les dépendances du constructeur.

Un chiffre tiré des ventes : SalesMeasure

Un montant ou un nombre de ventes n'a pas de requête à écrire : étendez la classe abstraite SalesMeasure. Elle fournit measure() et series() à partir des totaux figés sur les commandes (SalesTotals), restreints par la SaleDefinition, rangés par jours de la boutique, intervalles vides compris. Restent key(), label(), description(), unit() et la méthode protégée value(array $totals): MeasureValue, où $totals vaut array{orders: int, excl_tax: BigDecimal, incl_tax: BigDecimal} — à zéro pour un intervalle sans vente : un rapport sur zéro vente rend MeasureValue::money(null, null), comme le panier moyen. Les SalesTotals injectés au constructeur ($this->sales) convertissent un BigDecimal en Money dans la devise de la boutique (money()) et disent la règle des ventes en une phrase (definition()), à reprendre à la fin de la description(). Revenue, OrderCount et AverageOrderValue sont écrits ainsi.

Un indicateur qui compte des ventes sans passer par ces totaux — des lignes de commande, un classement — ne réécrit pas la règle : il reçoit la SaleDefinition du conteneur, restreint sa requête par constrain(), la date par dateColumn(), et reprend description() à la fin de la sienne, comme BestSellers et TopCustomers. Tous deux reçoivent aussi les SalesTotals, pour leurs montants : SalesTotals::decimal() lit une somme en décimal exact (SQLite additionne les colonnes décimales en flottants), puis money() la rend dans la devise de la boutique.

Un Ranking se déclare de même. Ce qu'il doit respecter — la première façon de classer est celle par défaut, un classement ou un filtre inconnu lève une InvalidArgumentException, un filtre qui reçoit une liste vide ne retient rien — est décrit dans les classements. Ceux du cœur départagent en dernier par identifiant, pour qu'à chiffres égaux l'ordre ne change pas d'un appel à l'autre.

Remplacer ou retirer un indicateur du cœur ​

Déclaré sous une clé déjà prise, un indicateur remplace le précédent — avec DefaultIndicatorRegistry, il reprend sa place dans all() —, et forget() en retire un. Les indicateurs du cœur sont final : un remplaçant ne les étend pas, il implémente le contrat ou étend SalesMeasure. Le cœur déclare les siens dans le boot() de son provider : si le vôtre démarre avant, le cœur écrase votre remplaçant et votre forget() reste sans effet. Attendez donc que l'application ait démarré :

php
use Slab\Framework\Core\Indicator\Contracts\IndicatorRegistry;

public function boot(): void
{
    $this->app->booted(function (): void {
        $indicators = $this->app->make(IndicatorRegistry::class);

        $indicators->add(RevenueWithoutShipping::class); // sa key() rend 'revenue' : il remplace celui du cœur
        $indicators->forget('top_customers');
    });
}

Changer la règle plutôt que cinq indicateurs

Pour ne compter une vente qu'une fois réglée, ne remplacez pas revenue, orders, average_order_value, best_sellers et top_customers : liez votre propre SaleDefinition (voir Ce qui compte comme une vente). Les cinq la suivent, leur description() comprise, et la popularité du catalogue avec eux — cinq remplaçants recopieraient la règle cinq fois, et le catalogue garderait l'ancienne.

Cas particulier : les icônes de menu ​

Une entrée de menu du front peut porter une icône. Le cœur n'en déclare aucune, et c'est délibéré : les icônes appartiennent au thème, seul à savoir ce que sa police — ou son jeu de SVG — contient. Figer une liste dans le cœur condamnerait tout thème qui n'a pas les mêmes.

Le cœur ne retient donc qu'une clé sur l'entrée, et tient le registre où le thème déclare les siennes.

Depuis le boot() du provider du thème :

php
use Slab\Framework\Core\Menu\Contracts\MenuIconRegistry;

public function boot(): void
{
    $icons = $this->app->make(MenuIconRegistry::class);

    $icons->add('cart', fn (): string => __('Panier'));
    $icons->add('user', fn (): string => __('Compte client'));
}

La clé (cart) est ce que le cœur stocke et ce que votre thème rend ; le paquet frontend livré la pose en classe CSS menu-icon icon-<clé> sur son composant d'entrée de menu.

Le libellé est une Closure, pas une chaîne

add(string $key, Closure $label) : le libellé est résolu à l'affichage par all(), pas à la déclaration. Un thème enregistre au boot, quand la langue de l'écran n'est pas encore connue — passer __('Panier') directement figerait la traduction.

DefaultMenuService::getItemFormData() passe all() — clé => libellé traduit, dans l'ordre de déclaration — au formulaire d'entrée de menu, et deux comportements en découlent :

  • liste peuplée → le back-office propose une liste déroulante, et la validation de icon ajoute un Rule::in(…) sur les clés déclarées : une clé que le thème ne saurait pas rendre est écartée ;
  • liste vide → le champ redevient une saisie libre, sans contrainte de liste. Une boutique dont le thème ne renseigne pas son jeu reste administrable, au lieu de se voir proposer une liste vide.

La dernière déclaration d'une clé gagne : un thème enfant redéclare cart pour en changer le libellé sans toucher au parent. has(string $key): bool répond à votre propre code — un package qui ne pose son icône que si le thème la fournit ; il rend toujours false quand rien n'est déclaré, puisque c'est alors la saisie libre qui prend le relais.

Déclarer une source du sitemap ​

Le cœur sait annoncer ce qu'il connaît : les pages de contenu, les rayons, les produits. Les marques qu'un package ajoute, les réalisations d'une vitrine, il ne saurait pas les nommer. Le SitemapSourceRegistry est l'endroit où chacun déclare sa famille d'adresses, plutôt qu'un service du cœur qui énumérerait ce qu'il n'a pas.

Chaque source écrit son fichier, que l'index rassemble — un package ajoute les siennes sans toucher à celles des autres, et aucun fichier n'approche les 50 000 adresses que la norme admet.

Une source ​

Une SitemapSource tient en quatre valeurs :

keynomme le fichier (brands → brands.xml) — donc unique : reprendre une clé remplace la source précédente
urlsun callable(): iterable<SitemapUrl>, rendu à la demande : un catalogue entier ne tient pas en mémoire
modelsce qui, changeant, périme cette source
priorityl'ordre dans l'index (10 pour pages, 20 pour categories, 30 pour products ; 50 par défaut)
php
namespace Acme\Brands\Sitemap;

use Acme\Brands\Models\Brand;
use Generator;
use Slab\Framework\Core\Sitemap\Contracts\SitemapSourceRegistry;
use Slab\Framework\Core\Sitemap\SitemapSource;
use Slab\Framework\Core\Sitemap\SitemapUrl;

final class BrandSitemapSources
{
    public static function register(SitemapSourceRegistry $registry): void
    {
        $registry->register(new SitemapSource('brands', self::brands(...), [Brand::class], priority: 40));
    }

    /**
     * @return Generator<int, SitemapUrl>
     */
    public static function brands(): Generator
    {
        foreach (Brand::query()->active()->lazy() as $brand) {
            yield new SitemapUrl(
                loc: route('front.brands.show', $brand),
                lastModified: $brand->updated_at,
                changeFrequency: SitemapUrl::WEEKLY,
                priority: 0.6,
            );
        }
    }
}

Puis, dans le boot() du provider du package — comme le cœur le fait de ses propres sources :

php
use Slab\Framework\Core\Sitemap\Contracts\SitemapSourceRegistry;

BrandSitemapSources::register($this->app->make(SitemapSourceRegistry::class));

N'annoncez que ce que le site montre

Un brouillon, une page programmée, un rayon vide : annoncer une adresse qui répond 404 use le passage d'un robot pour rien. C'est à la source de filtrer — le cœur ne devine pas ce que « publié » veut dire chez vous. Une source qui ne rend aucune adresse n'écrit pas de fichier, et celui qu'elle avait laissé disparaît de l'index.

Affiner ce que le cœur annonce ​

Le cœur ne sait pas ce qu'un rayon désactivé signifie : cette notion appartient au package qui l'apporte. Il annonce donc tous les rayons, et le package reprend la clé pour poser sa règle :

php
$registry->register(new SitemapSource(
    'categories',
    fn (): iterable => Category::query()->notRoot()->published()->lazy()
        ->map(fn (Category $category): SitemapUrl => new SitemapUrl($category->url, $category->updated_at)),
    [Category::class],
    priority: 20,
));

forget('categories') retire une source sans la remplacer — une boutique qui n'annonce pas ses rayons du tout. sources() rend la liste par priorité croissante, puis dans l'ordre de déclaration ; une source substituée reprend ce rang, comme si elle venait d'arriver.

La régénération suit vos modèles ​

Les models déclarés ne servent pas qu'à documenter : le cœur relève watchedModels() — toutes sources confondues, dédoublonné — et branche saved / deleted dessus. Un enregistrement planifie alors une régénération différée (SitemapGenerator::queueRegeneration()), jamais une génération : on ne fait pas attendre un marchand pendant qu'on parcourt le catalogue. Le job est unique et retardé de shop.sitemap.debounce_minutes (3 par défaut), de sorte qu'un import ou une édition en masse ne déclenche qu'une écriture ; shop.sitemap.enabled à false n'en planifie aucune.

Déclarez au boot(), pas plus tard

Le cœur branche ces observateurs une fois tous les paquets démarrés ($this->app->booted(...)). Une source déclarée après coup figure bien dans le sitemap, mais ses modèles ne sont plus écoutés : elle n'attendra la prochaine génération complète.

Générer tout de suite reste possible — php artisan sitemap:generate écrit sans passer par la file ; c'est ce qu'on veut après une reprise de données. Hors local et testing, la commande tourne aussi chaque nuit, comme filet pour les écritures faites hors application.

Ce que la source n'a pas à porter

Les versions par langue (hreflang) sont posées par le générateur sur les langues actives de la boutique — voir i18n. Et une adresse qui ne s'écrit pas (une route à qui il manque un paramètre) est journalisée puis ignorée : elle n'emporte pas le reste du fichier. Quant à l'écriture elle-même, elle se remplace sans toucher aux sources — le contrat SitemapGenerator est substituable comme n'importe quel service du conteneur, pour une boutique qui pousse son sitemap sur un stockage objet.

→ SitemapSourceRegistry dans la référence d'API, et la liste complète des surfaces dans les points d'extension.

Décrire une page aux machines : le StructuredDataRegistry ​

Le problème ​

Une fiche produit doit dire aux moteurs et aux assistants ce qu'elle montre : un nom, un prix, une disponibilité — mais aussi une image et une marque, que le cœur ne connaît pas. Si chaque page composait son JSON-LD, un package qui apporte la marque devrait forker la vue du produit pour l'annoncer.

Le principe ​

Le cœur tient un registre de sources, déclarées pour un modèle. À l'affichage, chacune reçoit le même sac (StructuredData) et y pose un graphe (add()) ou complète celui d'un type déjà posé (merge()), par priorité croissante.

Le cœur décrit à la priorité 10 ce qu'il connaît : Product (nom, référence, URL, Offer avec prix converti et disponibilité schema.org), CollectionPage pour une catégorie, WebPage pour une page de contenu. Rien à câbler côté thème : <x-front.metas :model="$model"/> appelle StructuredDataRegistry::for() et rend les graphes.

Étendre : compléter ce que le cœur a posé ​

Depuis le boot() de votre provider :

php
use Slab\Framework\Core\Seo\Contracts\StructuredDataRegistry;
use Slab\Framework\Core\Seo\StructuredData;
use Slab\Framework\Models\Product;

$this->app->make(StructuredDataRegistry::class)->register(
    Product::class,
    function (Model $product, StructuredData $data, array $context): void {
        if (! $product instanceof Product) {
            return;
        }

        $data->merge('Product', array_filter([
            'image' => $product->main_image?->url,
            'brand' => ['@type' => 'Brand', 'name' => (string) $product->brand?->name],
        ], fn ($value): bool => $value !== null));
    },
    priority: 50,
);

Un modèle que le cœur ne connaît pas du tout (une marque, une réalisation) se décrit entièrement avec add() — le @context est ajouté pour vous.

merge() écrase la clé qu'il touche

Une valeur null remplacerait ce que le cœur a posé, puis disparaîtrait au rendu : la description du produit serait perdue. D'où le array_filter — on ne complète qu'avec ce qu'on a vraiment.

Rien d'orphelin, rien de vide

merge() sur un type que personne n'a posé ne fait rien : un package désinstallé, ou une page où le cœur ne pose pas ce graphe, et la page reste décrite sans lui. toArray() élague les valeurs nulles ou vides — « ce produit n'a pas de marque » plutôt que « on n'en sait rien ».

Sous-classes et ordre

Une source déclarée pour Product sert aussi les classes qui en héritent : une boutique qui étend le modèle du cœur garde ce qu'il en dit. Les sources sont triées à l'appel de for(), pas à la déclaration — l'ordre de démarrage des providers n'a donc pas d'incidence ; à priorité égale, l'ordre de déclaration tient.

Ce que la page sait et que le modèle ignore ​

Le second argument de for() est un contexte libre, passé par la vue :

blade
<x-front.metas :model="$category" :context="['products' => $products]"/>

C'est ainsi qu'un rayon annonce la liste qu'il affiche — la page courante seulement, pas le catalogue. Une page sans entité (les résultats de recherche) construit son propre StructuredData et le rend par <x-front.json-ld :graphs="…"/>, comme le fait le fil d'Ariane.

CoreStructuredData::itemList($products, $name = null) construit cette liste : chaque ligne porte le produit décrit comme sa fiche, par le registre — la forme qu'un moteur lit en carrousel de produits. Le registre est alors appelé avec ['listed' => true] au contexte : une source qui décrit la fiche entière (mesures, livraison…) s'en tient, dans une liste, à ce que montre la carte.

Envelopper un graphe : take() ​

Une source qui doit ranger un graphe dans un autre le retire du sac par take($type), qui le rend sans son @context, puis pose le sien. C'est ainsi qu'une déclinaison devient la variante de son groupe (ProductGroup et son hasVariant). Déclarez-la en dernier (priorité haute) : les autres sources complètent le graphe avant qu'il ne change de place, et ne le trouveraient plus après.

Contrats et signatures exacts : points d'extension et référence d'API ; le registre est listé avec les autres liaisons du cœur dans le conteneur.

À retenir ​

  • On ajoute à une liste, on ne remplace pas le cœur.
  • Tout se fait au boot(), par add() / register() / extend().
  • L'ordre se règle par priorité (souvent réordonnable en config).