Apparence
Commande & checkout
Le tunnel de commande appartient au front ; le cœur possède la transformation panier → commande et l'agrégation des choix. Voici comment l'étendre.
Le tunnel : des étapes
Le frontend enregistre des étapes dans le CheckoutStepRegistry (un registre), triées par priorité — par défaut user (10), addresses (20), shipping (30), payment (40).
Ajouter une étape :
php
use Slab\Frontend\Checkout\Contracts\CheckoutStepRegistry;
$this->app->make(CheckoutStepRegistry::class)
->add('cadeau', GiftStepController::class, priority: 25, translations: 'mon-package');Le dernier paramètre est l'espace de noms des traductions de l'étape : son libellé et son slug d'URL sont lus en <ns>::checkout-steps.cadeau.{name,slug}. Sans lui (frontend par défaut), un package devrait écrire ses libellés dans les fichiers du front — c'est-à-dire les forker. Le registre restitue les étapes par ordered(), l'une d'elles par get($key). ordered() ne rend que les étapes qui ont lieu d'être : celle dont le contrôleur répond non à isApplicable() (vrai par défaut) n'existe pas — aucun parcours ne la montre, aucune garde ne l'attend.
Réordonner sans code : config/checkout.php (clé checkout.priorities). Remplacer toute l'orchestration : bind le contrat CheckoutFlow.
Le parcours : une page par étape, ou une seule
CheckoutFlow n'enchaîne pas seulement les étapes : il pose aussi les routes du tunnel (routes()). C'est le parcours qui sait par où il passe, et changer de parcours ne demande donc pas de forker le fichier de routes du front. Le frontend en livre deux :
DefaultCheckoutFlow(lié par défaut) — une page par étape, deux routes par étape, et la garde séquentielle qui renvoie à la première étape non complétée ;SinglePageCheckoutFlow— toutes les étapes empilées dans un seul formulaire : deux routes en tout, plus une routerefreshqui recompose le récapitulatif sur la saisie en cours sans rien engager.getNextStepUrl()y rend toujoursfalse(la soumission place la commande) et l'URL d'une étape devient une ancre dans la page, ce qui garde entier ce qui pointait vers elle.
php
use Slab\Frontend\Checkout\Contracts\CheckoutFlow;
use Slab\Frontend\Checkout\SinglePageCheckoutFlow;
$this->app->bind(CheckoutFlow::class, SinglePageCheckoutFlow::class);Le registre reste la source dans les deux cas : une étape contribuée par un package devient une page de plus, ou une section de plus — aucune vue n'est forkée.
Les choix : des options en donnée
Une feature qui ajoute un choix (transporteur, paiement) n'expose que de la donnée via un CheckoutOptionProvider — jamais la mise en page du tunnel. Elle déclare une exigence (requirement() : shipping, payment…) et renvoie depuis options(Cart $cart): iterable des CheckoutOption : requirement (l'exigence que l'option couvre), id, label, description, surcoût HT/TTC facultatif (priceTe/priceTi, null si neutre), metadata d'affichage libre, plus trois échappatoires déclaratives — fields (schéma de sous-formulaire), content et slot.
Ces deux derniers sont ce que l'option a à montrer une fois choisie : les coordonnées d'un virement, le formulaire de carte d'une solution intégrée. content porte du HTML rendu tel quel — c'est un contenu du back-office, pas une saisie du client — et slot la vue que la feature remplit ; slot prime sur content quand les deux sont là. Le front n'ouvre ce panneau que sous l'option retenue, et n'a toujours rien à connaître de la feature.
Le prix de l'option est une façade
Le montant porté par une CheckoutOption sert à l'affichage. Le surcoût réel est ajouté aux totaux par le TotalsModifier de la feature, qui relit la sélection du panier — voir Pricing & taxe.
Le frontend les rend génériquement :
php
use Slab\Framework\Core\Checkout\Contracts\CheckoutOptionRegistry;
$this->app->make(CheckoutOptionRegistry::class)->register(MonOptionProvider::class);for($cart) renvoie les options regroupées par exigence, dans l'ordre d'enregistrement des providers ; offers($requirement) dit si un provider installé sert une exigence, quoi qu'il propose pour un panier donné. Côté front, chaque exigence est rendue par une sous-classe triviale de l'OptionStepController (c'est le cas de shipping et payment), qui déclare aussi son message d'indisponibilité (unavailable()). L'étape n'existe que si son exigence est servie (offers()) : sans paquet de livraison, pas d'étape de livraison. Servie mais sans option pour ce panier — aucun transporteur actif, un colis trop lourd, une zone non desservie —, elle ne disparaît pas : elle arrête la commande avec ce message, plutôt que de laisser passer une commande que rien ne livrera ou ne réglera. Le choix retenu est persisté dans les checkout_selections du panier, puis recopié sur la commande. Le champ porte le nom de l'exigence (options[shipping], options[payment]) — sur une page qui les rend toutes, un option_id unique les aurait écrasées l'une l'autre —, et la première option est retenue dès l'affichage : le récapitulatif compte ainsi ce que l'écran montre coché.
Ajouter un transporteur le fait apparaître au checkout, sans toucher au front. C'est le modèle de slab/carrier et slab/payment.
Une commande passée n'est plus un tunnel
content et slot sont ce que l'option montre là où l'on choisit. Ce que le choix figé a à dire ensuite — le délai annoncé, un numéro de suivi, les coordonnées d'un virement à honorer — passe par le hook front.order.selection.<exigence>, ouvert sous chaque sélection de la commande (voir hooks & slots). Un formulaire de carte n'y a plus rien à faire ; un reçu n'avait pas de sens avant. Le libellé affiché vient de l'instantané (data.label), jamais de l'option : un transporteur renommé ou retiré ne change pas ce que la commande a annoncé.
Les frais de port : une grille, et ce qu'elle mesure
Un transporteur tarife par sa stratégie — un FQCN dans carriers.service, résolu par le conteneur. Nulle, c'est GridCarrierService, le service par défaut : le prix vient de la grille administrable du transporteur.
Une stratégie ne tarife pas un panier, mais une base de calcul — ShippingBasis : le total produits TTC, la zone de livraison, la devise, le poids, la quantité d'articles, le nombre de colis, et les lignes portées telles quelles (produit et quantité, ce dont une API transporteur a besoin). Elle se construit depuis un panier vivant (fromCart()) comme depuis une commande déjà passée (fromOrder(), sur son instantané) : c'est ce qui permet de retarifer une commande au back-office quand un administrateur en change le transporteur — retirer un article peut alors faire retomber les frais dans une tranche inférieure. Elle se construit aussi depuis ce qu'on envisage d'acheter (fromProducts($lines, $totalProductsTi, $areaId)), pour répondre à « combien coûte la livraison, et sous quel délai ? » devant une fiche produit, sans rien créer : ni panier, ni adresse, ni session. Le total lui est donné plutôt que calculé — une grille franco de port dépend du montant, et le montant dépend du client. On interroge alors un transporteur par isAvailableForBasis() puis getPriceFor().
Une ligne de grille (carrier_rates) pose des bornes facultatives sur ce qu'on mesure — montant, poids, quantité d'articles, nombre de colis — plus une zone, une devise et une règle de taxe (celle de la ligne l'emporte sur celle du transporteur), toutes trois facultatives elles aussi. Toutes les bornes renseignées doivent être satisfaites ; les bornes basses sont inclusives, les hautes exclusives, de sorte que deux tranches qui se suivent (0–5, 5–30) ne se disputent jamais un panier. La ligne la plus précise l'emporte — une zone nommée passe avant « toutes les zones », puis une devise nommée —, l'ordre de la grille départage le reste. Une ligne sans aucune borne vaut pour tout panier : c'est exactement un prix fixe.
Aucune ligne ne convient ? Le transporteur ne dessert pas ce panier et ne s'affiche pas au tunnel. C'est ainsi qu'on dit « pas au-delà de 30 kg » : en n'écrivant pas de ligne au-delà.
Un paquet apporte son transporteur, pas une option de menu
L'écran ne propose jamais de choisir une stratégie. Un paquet qui sait faire autrement — interroger l'API d'un transporteur, par exemple — installe ses propres transporteurs avec leur service ; on les désactive si on n'en veut pas. Il étend la classe CarrierService : la vue de sa configuration (getFormFields()), sa validation (validate(), setConfig()), puis isAvailableFor(ShippingBasis $basis) et getPriceFor(ShippingBasis $basis, bool $ti = false). Les entrées, elles, sont final — isAvailableForCart() / getPriceForCart(), isAvailableForOrder() / getPriceForOrder() — et c'est là que les zones desservies sont vérifiées, avant la stratégie : où un transporteur va est une donnée de sa fiche, pas de sa façon de calculer un prix. Reste orderContent(Order $order, array $selection): ?string, facultatif : ce que le transporteur retenu a à dire une fois la commande passée, rendu sous le hook front.order.selection.shipping.
Peser un panier
Le cœur ne porte ni poids ni conditionnement — ce sont des données de fiche produit, et un catalogue nu n'en a pas. ShippingMetricsResolver est le contrat qui les fournit :
php
use Slab\Carrier\Shipping\Contracts\ShippingMetricsResolver;
$this->app->bind(ShippingMetricsResolver::class, MaBalance::class);resolve(Collection $lines): ShippingMetrics rend le poids et le nombre de colis, à partir des lignes réduites à l'essentiel (product_id, quantity) ; provides(): list<BillingMetric> déclare ce que l'implémentation sait mesurer en plus de ce que le cœur mesure toujours (le montant et la quantité d'articles) — et l'écran de grille n'affiche que ces colonnes-là, plutôt que d'inviter à borner un poids que personne ne calcule. L'implémentation du cœur (DefaultShippingMetricsResolver) rend zéro, et le dit.
La commande : un snapshot immuable
À la dernière étape, CartService::createOrder() délègue au contrat OrderPlacementPipeline :
php
public function place(Cart $cart, ?OrderStatus $initialStatus = null, ?Currency $currency = null): Order;Sans devise explicite, c'est celle du client (preferred_currency) qui est retenue.
L'implémentation par défaut (DefaultOrderPlacementPipeline) est transactionnelle et idempotente : un panier déjà converti rend sa commande, et la contrainte unique orders.cart_id sert de filet. Avant toute écriture — hors transaction — elle propose OrderPlacing : un refus lève ActionCancelled et rien n'est écrit. Puis, en une transaction, elle :
- crée l'en-tête de commande (devise, adresses répliquées,
checkout_selectionscopiées, le mot du clientcustomer_commentrecopié — le panier sera vidé, la demande reste —, jeton unique) ; - fige les lignes dans des
OrderProduct(référence, nom traduit, quantité, drapeau cadeauis_gift, prix HT/TTC du moment et prix de base, en devise de référence et de la commande) ; - fige les totaux : les lignes contribuées par les features dans
price_components(JSON), les produits et le grand total dans des colonnes, en devise de référence et du client — le grand total est la somme générique, le cœur ne nomme aucune feature ; les totaux sont calculés enMoneyet l'arrondi n'a lieu qu'ici, au bord ; - applique le statut initial s'il en reçoit un qui existe en base — par l'
OrderService, qui date donc ses jalons et émetOrderStatusChanged: le mail de confirmation part de là ; - émet
OrderPlaced.
Résultat : une commande se relit sans aucun package installé — l'OrderPriceService lit les colonnes figées et rend du Money, sans jamais recalculer, et TotalsBuilder::forOrder() relit les lignes depuis price_components. Supprimer un transporteur après coup ne change pas une commande passée.
php
use Slab\Framework\Core\Order\Contracts\OrderPriceService;
app(OrderPriceService::class, ['order' => $order])->getTotalPrice(ti: true);Trois niveaux d'extension, sans jamais forker le cœur : empêcher le placement en écoutant OrderPlacing, y réagir en écoutant OrderPlaced, ou le remplacer entièrement en rebindant OrderPlacementPipeline. Il n'y a pas de registre d'étapes de placement.
Le statut : des jalons datés
Le cycle de vie passe par l'OrderService, lui aussi scopé à une commande :
php
use Slab\Framework\Core\Order\Contracts\OrderService;
app(OrderService::class, ['order' => $order])->changeStatus($status);changeStatus() rend l'OrderStatusHistory courant. Rien ne se passe si le statut demandé est déjà le statut courant — relu en base plutôt que pris sur la relation chargée, pour que deux changements d'affilée sur la même instance ne rejouent pas une transition qui vient d'avoir lieu, et comparé sur l'identité, pas sur le nom. Sinon le service propose OrderStatusChanging (un refus lève ActionCancelled), écrit un OrderStatusHistory où le nom du statut est recopié comme instantané, date les jalons franchis, puis émet OrderStatusChanged — qui porte aussi l'historique précédent (previous, null à la toute première transition), pour qu'une machine à états sache d'où l'on vient sans avoir à sauter celui qu'on vient d'écrire.
Ces jalons — validated_at, shipped_at, delivered_at, paid_at, cancelled_at, locked_at (avec locked_by_status_id) — sont des cliquets : écrits une seule fois, jamais recalculés depuis l'historique. shipped_at dit qu'une commande est partie, delivered_at qu'elle est arrivée : sans les deux, un écran qui veut distinguer un colis en transit d'un colis reçu n'aurait d'autre recours que de reconnaître les statuts à leur nom, alors qu'ils sont de la donnée administrée. Revenir à un statut ordinaire ne les défait pas, et décocher plus tard le drapeau d'un statut ne réécrit pas ce que des commandes ont vécu.
Une vente de plus ou de moins change le catalogue
Quand des jalons datés font entrer une commande dans les ventes — ou l'en font sortir, annulée après validation —, le service oublie le cache de popularité et émet ReindexRequested sur toute la famille des produits commandés — voir Recherche. Il relit la définition d'une vente plutôt que de nommer un jalon : une boutique qui la substitue est suivie.
L'e-mail éventuel n'est pas envoyé par le service : un listener queueable (SendOrderStatusMail) écoute OrderStatusChanged. Le catalogue proposé au back-office vient de l'OrderMailRegistry (que OrderStatusService::getMailablesList() relit) ; un package y ajoute le sien depuis son provider :
php
use Slab\Framework\Core\Order\Contracts\OrderMailRegistry;
$this->app->make(OrderMailRegistry::class)->add(ShippingMail::class);Sans libellé explicite, un OrderStatusMail est étiqueté par son sujet (::getSubject()) ; à défaut, par le nom court de la classe.
Ce qui compte comme une vente
Le chiffre d'affaires, les indicateurs de ventes et la popularité du catalogue comptent tous des ventes. S'ils ne retenaient pas les mêmes commandes, la boutique dirait deux choses différentes de ce qui se vend — et le tableau de bord, un agent et le listing se contrediraient. Le contrat SaleDefinition pose la règle une fois :
constrain(Builder|EloquentBuilder $query, string $orders = 'orders'): voidrestreint une requête sur les commandes (Query Builder ou Eloquent) à celles qui comptent comme ventes ;dateColumn(string $orders = 'orders'): stringrend la colonne qui date une vente — le jour où elle compte ;description(): stringdit la règle en une phrase, que les indicateurs reprennent pour qu'on lise leurs chiffres sans se tromper sur ce qu'ils comptent.
$orders est la table des commandes dans la requête, ou son alias : qualifiez chaque colonne par lui.
Par défaut (DefaultSaleDefinition), une vente est une commande validée et jamais annulée — validated_at renseigné, cancelled_at vide —, datée du jour de sa validation. La règle s'appuie sur les cliquets, jamais sur le nom d'un statut : une commande qui attend son virement n'est pas encore une vente, une commande validée puis annulée n'a rien vendu.
Une boutique qui ne compte une vente qu'une fois réglée lie sa propre définition, et tout ce qui compte des ventes la suit :
php
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder;
use Slab\Framework\Core\Order\Contracts\SaleDefinition;
final class PaidSaleDefinition implements SaleDefinition
{
public function constrain(Builder|EloquentBuilder $query, string $orders = 'orders'): void
{
$query->whereNotNull($orders.'.paid_at')->whereNull($orders.'.cancelled_at');
}
public function dateColumn(string $orders = 'orders'): string
{
return $orders.'.paid_at';
}
public function description(): string
{
return __('Une vente est une commande réglée et jamais annulée, datée du jour de son règlement.');
}
}php
$this->app->bind(SaleDefinition::class, PaidSaleDefinition::class);Les indicateurs
Le chiffre d'affaires du mois, le panier moyen, les meilleures ventes : recalculés par chaque écran, ils finiraient par diverger — l'un compterait les frais de port et l'autre non, l'un daterait une vente du jour où le client a commandé, l'autre de sa validation. Le cœur les déclare une fois, sous une clé stable, dans l'IndicatorRegistry (un singleton) : tout ce qui les affiche — un écran, un agent — dit alors les mêmes chiffres.
Un indicateur est un Indicator : une clé unique dans le registre (key()), un libellé (label()) et une description() qui dit ce qu'il compte, et comment — quelles commandes, hors taxes ou non, à quelle date. C'est elle qui permet de lire le chiffre sans se tromper. On n'implémente jamais Indicator seul, mais l'un de ses deux cas : Measure, un chiffre sur une période et sa série, ou Ranking, un classement.
Le cœur déclare les siens à son démarrage (CoreIndicators), comme le ferait un package :
| Clé | Classe | Forme | Ce qu'il compte |
|---|---|---|---|
revenue | Revenue | Measure, montant | la somme des ventes, frais de port et remises compris, HT et TTC, dans la devise de la boutique |
orders | OrderCount | Measure, nombre | le nombre de ventes |
average_order_value | AverageOrderValue | Measure, montant | le chiffre d'affaires divisé par le nombre de ventes — vide quand il n'y en a aucune |
new_customers | NewCustomers | Measure, nombre | les comptes clients créés sur la période, désactivés depuis compris — l'équipe n'en est pas |
best_sellers | BestSellers | Ranking | les produits les plus vendus, par famille |
top_customers | TopCustomers | Ranking | les clients qui achètent le plus, comptes et invités |
Tous, sauf new_customers, comptent des ventes au sens de la règle ci-dessus et ajoutent sa phrase à leur description() : lier une autre SaleDefinition les change tous les cinq, texte compris. Les trois premiers lisent les totaux figés sur la commande (l'instantané), jamais un recalcul, et le panier moyen se calcule sur les mêmes totaux que les deux autres : celui d'un trimestre n'est pas la moyenne de ceux de ses trois mois.
Ajouter un indicateur, remplacer ou retirer l'un de ceux du cœur : voir Déclarer un indicateur.
Lire un chiffre
php
use Slab\Framework\Core\Indicator\Contracts\IndicatorRegistry;
use Slab\Framework\Core\Indicator\Contracts\Measure;
use Slab\Framework\Core\Indicator\Interval;
use Slab\Framework\Core\Indicator\Period;
$revenue = app(IndicatorRegistry::class)->find('revenue');
if ($revenue instanceof Measure) {
$september = Period::between('2026-09-01', '2026-09-30');
$value = $revenue->measure($september); // MeasureValue : ->exclTax, ->inclTax
$august = $revenue->measure($september->previous()); // août entier
$lastYear = $revenue->measure($september->yearBefore()); // septembre 2025
$weeks = $revenue->series($september, Interval::Week); // list<SeriesPoint>
}find() rend null pour une clé inconnue — un package désinstallé, un indicateur retiré — et un Ranking n'a pas de measure() : d'où le instanceof. all() rend les indicateurs par clé, dans l'ordre de déclaration.
- Une période se compte en jours entiers, bornes comprises.
Period::between()accepte des chaînes comme des dates Carbon, et ramène ses bornes ($period->from,$period->until) au début du premier jour et à la fin du dernier : une vente passée à 23 h 59 ne tombe jamais hors du mois qui la contient. Ce sont des jours de la boutique : minuit est celui de son fuseau (ShopTime, réglé parshop.timezone), pas celui de la base, qui reste en UTC — une vente passée à 0 h 30 à Paris est du jour qui commence. Une chaîne sans fuseau s'entend à l'heure de la boutique ; une date Carbon garde son instant. Une fin qui précède le début lève uneInvalidArgumentException. previous()etyearBefore()donnent la période de comparaison.previous()rend celle qui précède, de même durée ; une période en mois entiers se compare aux mois entiers d'avant — septembre à août, et non aux trente jours qui commenceraient le 2 août.yearBefore()rend les mêmes jours un an plus tôt, et un mois entier le reste : février 2024 se compare à février 2023, jusqu'au 28.Intervalest le pas d'une série :Day,Week,Month,QuarterouYear. Un intervalle se désigne par son premier jour — le lundi d'une semaine, le 1er d'un mois —, si bien que le premier peut commencer avant la période : la semaine du mardi 1er septembre 2026 commence le lundi 31 août, mais seuls les jours de la période y sont comptés. Chaque date y est rangée selon son jour à la boutique, la nuit du changement d'heure comprise.count($period)dit combien d'intervalles couvrent une période,label()nomme le pas dans la langue courante.- Une série rend un point par intervalle, vides compris :
series()rend desSeriesPoint(start,value) dans l'ordre, et un intervalle sans vente y figure — à zéro pour une somme ou un nombre, vide pour le panier moyen. Une série qui sauterait les semaines creuses ferait croire à une activité continue.
Un montant vide n'est pas 0 €
Une MeasureValue porte son unité (unit : MeasureUnit::Money ou MeasureUnit::Count), puis count pour un nombre, ou exclTax et inclTax (des Money) pour un montant. Un montant peut valoir null : il n'y a rien à mesurer, comme le panier moyen d'une période sans vente. Affichez-le vide, jamais « 0 € » — un graphique ou un agent y liraient un panier moyen de zéro. Un nombre, lui, n'est jamais nul.
Les classements
Un Ranking classe, sur une période, ce qu'il connaît — des produits, des clients :
orderings(): les façons de classer, clé → ce qui départage ; la première est celle par défaut ;filters(): les filtres qu'il comprend, clé → ce qu'ils retiennent ; chacun reçoit une liste d'identifiants ;rank(Period $period, string $ordering, int $limit, array $filters = []): les$limitpremiers, du premier au dernier, enlist<RankedItem>.
php
use Slab\Framework\Core\Indicator\Contracts\IndicatorRegistry;
use Slab\Framework\Core\Indicator\Contracts\Ranking;
use Slab\Framework\Core\Indicator\Period;
$bestSellers = app(IndicatorRegistry::class)->find('best_sellers');
if ($bestSellers instanceof Ranking) {
$top = $bestSellers->rank(
Period::between('2026-09-01', '2026-09-30'),
array_key_first($bestSellers->orderings()), // la façon de classer par défaut
limit: 10,
filters: ['category' => [$category->id]],
);
}Deux règles protègent qui lit un classement : une façon de classer ou un filtre inconnus lèvent une InvalidArgumentException au lieu d'être ignorés, et un filtre qui reçoit une liste vide ne retient rien — ce n'est pas « pas de filtre ».
Chaque place est un RankedItem en lecture seule : subject, le modèle classé (une famille de produits, un client), pour qu'un écran ou un agent sache y renvoyer ; name, le nom à afficher ; orders, le nombre de ventes ; quantity, les unités vendues — null quand le classement ne porte pas sur des produits ; revenueExclTax et revenueInclTax, en Money.
| Clé | Façons de classer (défaut en premier) | Filtres | Ce qu'il faut savoir |
|---|---|---|---|
best_sellers | orders, quantity, revenue (HT) | category, sous-catégories comprises | par famille : les déclinaisons additionnent leurs ventes sous leur produit parent, et deux déclinaisons dans la même commande restent une commande ; le chiffre d'affaires est celui des lignes, hors frais de port ; un produit supprimé depuis n'est plus classé |
top_customers | revenue (HT), orders | aucun | comptes et invités ; le chiffre d'affaires des commandes, frais de port compris ; un compte désactivé depuis garde sa place ; name est le nom du client, ou son e-mail à défaut |
Modifier une commande : le verrou d'abord
Un instantané n'est pas figé dès la première seconde : tant qu'aucun statut ne l'a verrouillée, une commande se corrige — une quantité, un prix négocié, une adresse fautive. Ce qui reste interdit, c'est de recalculer depuis le catalogue : l'OrderModificationService, lui aussi scopé à une commande, refait toujours les totaux à partir des lignes et des composants déjà figés.
php
use Slab\Framework\Core\Order\Contracts\OrderModificationService;
$modifications = app(OrderModificationService::class, ['order' => $order]);
$modifications->updateLine($line, quantity: 3, unitPriceTe: '149.9000');
$modifications->addProduct($product, quantity: 2);
$modifications->setComponent('shipping', __('Livraison'), '12.5000');
$modifications->updateAddresses(delivery: $address);Les montants sont des chaînes décimales : les colonnes sont en decimal(15, 4) et le calcul se fait en BigDecimal — un flottant n'a rien à faire au milieu.
Trois règles valent pour chaque geste :
- le verrou d'abord — une commande fermée par l'
OrderLockPolicyrefuse toute modification, et l'ActionCancelledlevée porte le motif rendu parreason(); - un plugin peut dire non — chaque geste propose d'abord un
OrderModifyingannulable, dont lechangeest une clé stable (line.updated,line.added,line.removed,component.updated,component.removed,addresses.updated) et lecontextce sur quoi il porte : on refuse une retouche de prix sans interdire l'ajout d'un produit. Le veto est levé avant la transaction, rien n'est écrit ; - les taux viennent de l'instantané — le taux de change se déduit du rapport entre les colonnes en devise de référence et celles en devise du client ; le taux de taxe d'une ligne, du rapport de son TTC à son HT. Reconvertir au cours du jour ferait dériver une commande passée il y a six mois, et la règle de taxe d'un produit a pu changer, pas ce que le client a payé. Faute d'instantané exploitable (commande à zéro, ligne offerte), le service retombe sur le taux courant de la devise et sur un rapport neutre — dire « pas de taxe » plutôt qu'en inventer une.
Une fois écrit, chaque geste s'annonce : OrderModified, émis sur la commande rechargée, porte la même clé change et le détail avant/après (changes). Le cœur ne tient pas de journal des modifications : ce qui doit en garder trace — un audit, un ERP — s'y branche comme le reste.
La remise n'est pas un champ
updateLine() ne connaît qu'une quantité et un prix unitaire HT. La ligne garde le prix catalogue du jour de la commande (base_price_*), et la remise est l'écart avec le prix retenu ; un champ de plus se contredirait avec lui à la première divergence. addProduct() fait exception dans un sens seulement : un produit ajouté aujourd'hui n'a aucun instantané, son prix ne peut donc venir que du catalogue du moment (ProductPriceService) — et se retouche ensuite comme n'importe quelle ligne.
Les lignes contribuées par les features se posent à la clé, par setComponent() / removeComponent() — le paquet transporteur y recalcule ses frais quand on change de transporteur (sa grille est alors appliquée à la commande telle qu'elle est, via ShippingBasis::fromOrder()), sans que le cœur ait à le nommer. Sans TTC explicite, il est déduit du taux que portait déjà cette ligne (et vaut le HT si elle n'existait pas), et une ligne remplacée reste à sa place : l'ordre des totaux est celui que le client a vu. refreshTotals() suit chaque geste qui touche aux montants — une adresse corrigée n'a rien à refaire —, et reste exposé pour un package qui toucherait l'instantané par un autre chemin.
Ce qui verrouille
Par défaut, c'est le cliquet : DefaultOrderLockPolicy ne lit que locked_at — jamais les drapeaux des statuts, ni l'historique. Décocher demain le drapeau d'un statut rouvrirait sinon des milliers de commandes déjà facturées. reason() rend la phrase que lira celui à qui on refuse la modification : la date, et le statut qui l'a posée s'il existe encore.
Un projet qui verrouille sur autre chose — période comptable close, commande transmise à l'ERP, encours dépassé — substitue le contrat, et l'écran comme le domaine suivent sans rien savoir de sa règle :
php
use Slab\Framework\Core\Order\Contracts\OrderLockPolicy;
$this->app->bind(OrderLockPolicy::class, AccountingPeriodLockPolicy::class);Pour ajouter une condition ponctuelle plutôt que remplacer la politique, il y a OrderModifying.
La note interne
L'équipe se parle sur une commande, et c'est un geste du domaine, porté par l'OrderService : l'écran du back-office et un agent l'écrivent par le même chemin.
php
use Slab\Framework\Core\Order\Contracts\OrderService;
app(OrderService::class, ['order' => $order])->addNote(__('Client relancé, à rappeler jeudi.'));addNote() rend l'OrderNote écrite, signée de qui est connecté. Une note s'ajoute : rien ne s'y réécrit, et une commande verrouillée se note encore — le verrou tient les lignes et les montants, pas le journal de l'équipe. Les notes ($order->notes, la plus récente en tête) ne sortent jamais du back-office, et leur auteur reste nommé quand son compte est désactivé depuis (author, corbeille comprise). Le mot du client, lui, est ailleurs : customer_comment, figé au placement.
Les documents : devis, bon de livraison, facture
Le cœur ne déclare aucun document, et c'est voulu : ce qu'un commerçant appelle une facture, ce qu'il y met et à partir de quand elle existe ne se décide pas au cœur. Un package déclare son type dans l'OrderDocumentRegistry — sa clé d'URL, son libellé, sa condition d'existence et son rendu — et il devient servable partout où une commande s'affiche :
php
use Slab\Framework\Core\Order\Contracts\OrderDocumentRegistry;
$this->app->make(OrderDocumentRegistry::class)->add(
key: 'invoice',
label: fn (): string => __('Facture'),
render: fn (Order $order) => $this->pdf($order),
available: fn (Order $order): bool => $order->paid_at !== null,
customer: true,
priority: 10,
);Le label est une closure parce qu'il est résolu à l'affichage : traduit dans la langue du moment, pas dans celle du démarrage. La condition s'appuie le plus souvent sur les cliquets posés par les statuts — une facture après paid_at, un bon de livraison après shipped_at, une enquête de satisfaction après delivered_at — c'est ce qui fait que ces documents suivent le cycle de vente au lieu d'un drapeau de plus à cocher. Sans available, le document existe toujours. customer: false le réserve au back-office (document de travail, bon de préparation).
for($order, customerOnly: true) rend les OrderDocument que cette commande peut produire — chacun sait dire son label() et se render($order) —, ordonnés par priorité croissante ; le tri est stable, donc à priorité égale l'ordre de déclaration tient et un package qui en ajoute un ne réordonne pas ceux qui étaient là. get('invoice') rend le type déclaré, ou null. La dernière déclaration d'une clé gagne : c'est ainsi qu'un package remplace la facture d'un autre.
Chercher des commandes : un vocabulaire, trois surfaces
Une commande se cherche depuis trois endroits : le listing du back-office (colonne par colonne), un agent (list_orders, qui reçoit un jeu de critères et pagine) et le compte client (borné à ses seules commandes). Le cœur n'écrit la traduction en SQL qu'une fois, pour qu'un filtre « statut » de l'écran et un filtre « statut » de l'agent ne finissent pas par désigner des commandes différentes.
La demande est un objet immuable et inerte, OrderCriteria : terms (texte libre — numéro de commande, client, référence ou nom d'un produit commandé), customerTerms, placedFrom / placedUntil, minTotal / maxTotal (des BigDecimal, sur le total TTC en devise de référence), statusIds, productIds, customer. Un champ vide n'est pas un critère : les textes sont nettoyés (searchTerms(), customerSearchTerms()), et isEmpty() dit que la demande porte sur toutes les commandes.
Ce qui l'exécute est le contrat OrderSearch, en trois entrées :
php
use Slab\Framework\Core\Order\Contracts\OrderSearch;
use Slab\Framework\Core\Order\OrderCriteria;
$search = app(OrderSearch::class);
$search->apply($query, new OrderCriteria(statusIds: [$paid->id])); // sur une requête déjà commencée
$search->query(new OrderCriteria(terms: 'perceuse')); // toutes celles qui répondent
$search->ownedBy($user, new OrderCriteria(placedFrom: now()->subYear())); // les siennes, et elles seulesLes trois rendent un Builder : le tri et la pagination restent à l'appelant — un tableau et un agent ne les veulent pas pareils.
Deux règles y vivent, qu'aucun appelant ne devrait avoir à réécrire : le statut courant n'est pas une colonne (c'est le dernier historique, départagé par id quand deux transitions partagent la seconde, exactement comme Order::status()), et un produit parent vaut pour ses déclinaisons — dans ce sens seulement : demander la perceuse bleue ne ramène pas la rouge.
La portée d'un client n'est pas un critère
ownedBy() applique la portée après les critères et hors de leurs groupes : aucun critère ne peut l'élargir. OrderCriteria::customer, lui, n'est qu'un filtre ordinaire. C'est aussi pourquoi chaque critère est posé dans son propre groupe — la recherche libre est une suite de or, et un or laissé au même niveau que la portée la contournerait.
Chercher aussi ce qu'un package est seul à connaître
Le cœur cherche ce qu'il connaît : le numéro, le client, les produits commandés. Un package en sait davantage — la contremarque d'un acheteur professionnel, un numéro de suivi, une référence d'entrepôt — et substituer OrderSearch pour une colonne de plus coûterait la recopie de ses deux règles dures, qui divergeraient au premier correctif. L'OrderSearchTermRegistry ouvre le texte libre, et lui seul :
php
use Illuminate\Database\Eloquent\Builder;
use Slab\Framework\Core\Order\Contracts\OrderSearchTermRegistry;
$this->app->make(OrderSearchTermRegistry::class)->contribute(
function (Builder $query, string $terms): void {
$query->orWhereHas('shipments', fn (Builder $shipment) => $shipment->whereLike('tracking_number', '%'.$terms.'%'));
},
);La clause reçoit la requête du groupe et le texte cherché ; elle y pose ses orWhere. Le registre est un singleton — il porte ce que les providers y ont déposé au démarrage — et all() restitue les clauses dans l'ordre d'inscription. Ce qu'un package y contribue vaut aussitôt pour les trois surfaces : c'est tout l'intérêt d'une recherche écrite une seule fois.
La clause reste dans le groupe
Elle est appliquée dans le groupe du texte libre, jamais à la racine de la requête. Un orWhere posé au même niveau que la portée d'ownedBy() la contournerait — il suffirait de chercher le bon mot pour voir les commandes des autres. C'est pourquoi un package contribue une clause et non une requête : il élargit ce qu'on cherche, jamais le périmètre de qui cherche.
Rebinder OrderSearch change ce que « chercher une commande » veut dire — chercher aussi dans les adresses, ignorer les commandes d'essai — sans qu'aucune des trois surfaces ne bouge. Décorer plutôt que remplacer ($this->app->extend(...), voir le conteneur) suffit le plus souvent.
Ce qu'une implémentation doit respecter
Trois disciplines, le reste est libre : rendre un Builder<Order> sans trier ni paginer ; poser toute condition qui contient un or dans son propre groupe (->where(fn (Builder $q) => …)), sinon elle escalade et peut contourner une portée ; et n'appliquer la portée d'ownedBy() qu'en dernier, jamais depuis les critères. OrderCriteria est final : un filtre propre à votre domaine (canal de vente, entrepôt…) se porte dans votre implémentation et dans les surfaces qui l'exposent, pas par extension de l'objet de critères. Et si vous remplacez DefaultOrderSearch au lieu de le décorer, relisez les clauses de l'OrderSearchTermRegistry : sans elles, ce que les packages avaient contribué disparaît des trois surfaces d'un coup.
Réagir à une commande
Pour réagir, écoutez les événements :
| Événement | Pour… |
|---|---|
OrderPlaced | e-mail, stock, ERP, fidélité |
OrderStatusChanged | notifications de suivi |
OrderModified | ERP, réémission d'un document, alerte d'un commercial |
CheckoutStarted / CheckoutStepCompleted / CheckoutCompleted | mesure du parcours, relance d'abandon, réservation de stock |
OrderPlacing (annulable) | contrôle anti-fraude, encours client — le placement est interrompu avant la transaction |
OrderStatusChanging (annulable) | machine à états : interdire une transition |
OrderModifying (annulable) | condition de verrouillage ponctuelle (période close, commande transmise) — rien n'est écrit |
CheckoutCompleted ne concerne que le parcours d'achat en ligne ; OrderPlaced constate la création d'une commande d'où qu'elle vienne (import, back-office, API). CheckoutStepCompleted est émis une fois par étape franchie, y compris sur un tunnel en une page — toutes d'un coup, juste avant le placement.
Points d'extension de ce domaine
| Je veux… | Point d'extension |
|---|---|
| ajouter une étape de tunnel | CheckoutStepRegistry (frontend) |
| ajouter un mode de livraison / paiement | CheckoutOptionProvider |
| savoir si une exigence (livraison, paiement…) est servie par un paquet installé | CheckoutOptionRegistry::offers() |
| montrer quelque chose sous l'option retenue | CheckoutOption (content / slot) |
| montrer quelque chose sous un choix figé d'une commande | hook front.order.selection.<exigence> (voir hooks) |
| tarifer autrement qu'à la grille (API transporteur…) | CarrierService (le paquet installe ses transporteurs) |
| fournir le poids et le conditionnement d'un panier | ShippingMetricsResolver (bind) |
| changer l'orchestration du tunnel | CheckoutFlow (bind — DefaultCheckoutFlow, SinglePageCheckoutFlow) |
| réagir à une commande | OrderPlaced, OrderStatusChanged, OrderModified |
| refuser une commande ou une transition de statut | OrderPlacing, OrderStatusChanging (voir événements) |
| remplacer le placement de commande | OrderPlacementPipeline (bind) |
| modifier une commande avant son verrouillage | OrderModificationService |
| poser une note interne sur une commande | OrderService::addNote() |
| refuser une modification de commande | OrderModifying (annulable) |
| changer ce qui verrouille une commande | OrderLockPolicy (bind) |
| changer ce qui compte comme une vente (chiffre d'affaires, popularité, indicateurs) | SaleDefinition (bind) |
| lire un chiffre ou un classement de la boutique (chiffre d'affaires, meilleures ventes…) | IndicatorRegistry (voir les indicateurs) |
| ajouter un indicateur, remplacer ou retirer l'un de ceux du cœur | IndicatorRegistry (voir registres) |
| relire les totaux figés d'une commande | OrderPriceService |
| chercher des commandes (back-office, agent, compte client) | OrderSearch + OrderCriteria |
| chercher aussi dans ce qu'un package possède (contremarque, suivi…) | OrderSearchTermRegistry (voir registres) |
| changer ce que « chercher une commande » veut dire | OrderSearch (bind) |
| proposer un e-mail transactionnel sur un statut | OrderMailRegistry (voir registres) |
| servir un document de commande (facture, bon de livraison…) | OrderDocumentRegistry |
greffer une relation sur Order | resolveRelationUsing |