Apparence
Les pipelines
Un pipeline transforme une valeur en la faisant traverser une chaîne de maillons, chacun contribué de l'extérieur, dans un ordre maîtrisé. C'est la mécanique du prix et des totaux — mais le concept est générique.
Le problème
Le prix d'un produit n'est pas une donnée figée : une promotion le baisse, une grille B2B le recalcule, la taxe s'applique à la fin… Ces transformations viennent de packages différents, qui ne se connaissent pas, et leur ordre compte. Le cœur ne doit connaître aucune d'elles.
Le principe
Une valeur (dans un contexte) traverse des maillons triés par priorité. Chaque maillon reçoit le contexte, le transforme, puis passe la main au suivant — exactement comme un middleware HTTP.
Écrire un maillon
Un maillon de prix implémente PriceModifier. Il mute le prix HT puis rend la main :
php
use Slab\Framework\Core\Pricing\Contracts\PriceModifier;
use Slab\Framework\Core\Pricing\PriceContext;
use Brick\Math\RoundingMode;
use Closure;
final class FideliteePriceModifier implements PriceModifier
{
public function handle(PriceContext $context, Closure $next): PriceContext
{
if (/* le client a un avantage fidélité */) {
$context->price = $context->price->multipliedBy('0.95', RoundingMode::HALF_UP);
$context->trace[] = ['fidelite', '-5%'];
}
return $next($context); // toujours passer la main
}
}Le PriceContext porte le produit, la quantité, le client, le price (un Brick\Money\Money, muté en chaîne) et une trace d'audit. On le construit avec la fabrique PriceContext::forProduct($product, $quantity, $customer), qui pose le prix catalogue HT (price_te, en devise de la boutique) comme base de la chaîne — le « Prix catalogue HT » du schéma.
L'enregistrer avec une priorité
Dans le boot() de votre provider — l'ordre croissant détermine l'enchaînement :
php
use Slab\Framework\Core\Pricing\Contracts\PriceModifierRegistry;
$this->app->make(PriceModifierRegistry::class)->add(FideliteePriceModifier::class, priority: 60);Priorités des maillons officiels : la grille B2B à 10, la promotion à 50. Un nombre plus petit s'applique plus tôt.
Taxe : toujours en fin de chaîne
Les maillons raisonnent en HT. Le pipeline résout lui-même le taux après tous les modificateurs, depuis la règle de taxe du produit et la zone courante (TaxRate::zero() si le produit n'a pas de règle) — vous n'avez jamais à vous en occuper. Les montants restent exprimés en devise de la boutique ; la lecture en devise du client se fait au bord.
Ce que rend le pipeline
PricePipeline::priceFor() est le point d'entrée unique — accessors produit, totaux, snapshot de commande, indexation passent tous par lui. Il rend un Price décomposé : base (catalogue HT), subtotalExclTax (HT après maillons), taxRate et trace.
php
use Slab\Framework\Core\Pricing\Contracts\PricePipeline;
use Slab\Framework\Core\Pricing\PriceContext;
$price = app(PricePipeline::class)->priceFor(PriceContext::forProduct($product, 3, $customer));
$price->exclTax(); // HT après modificateurs
$price->inclTax(); // TTC
$price->tax(); // la taxe seule
$price->exclTax(withModifiers: false); // le prix catalogue, pour un affichage « barré »Le second pipeline : les totaux
Le panier et la commande affichent des lignes de totaux (sous-total, livraison, remise…). Même mécanique, avec TotalsModifier qui contribue des lignes au lieu de muter une valeur :
php
use Slab\Framework\Core\Pricing\Contracts\TotalsModifier;
use Slab\Framework\Core\Pricing\{TotalsContext, TotalsLine};
final class EcoPartTotalsModifier implements TotalsModifier
{
/** @return list<TotalsLine> */
public function lines(TotalsContext $context): array
{
return [ new TotalsLine('eco_part', __('Éco-participation'), $exclTax, $inclTax) ];
}
}php
use Slab\Framework\Core\Pricing\Contracts\TotalsModifierRegistry;
$this->app->make(TotalsModifierRegistry::class)->add(EcoPartTotalsModifier::class, priority: 35);Le TotalsContext porte le subject (Cart ou Order), un drapeau currency (lire les colonnes en devise du client) et les lignes déjà contribuées : un maillon peut donc répondre à ce qui le précède, ce qu'aucune priorité seule ne dirait. $context->line('products') récupère une ligne posée plus tôt (null si personne ne l'a posée) :
php
$products = $context->line('products'); // toujours posée en premier par le cœurC'est ainsi qu'un bon de réduction « franco de port » annule la livraison : il pose sa contrepartie, il ne réécrit jamais la ligne d'un autre — et le total reste la somme.
Le cœur agrège : ligne « produits » + lignes contribuées = ligne « total », en sommant exactement en Money. Au passage en commande, ces lignes sont figées dans orders.price_components : TotalsBuilder::forOrder() les relit telles quelles, sans invoquer aucun modificateur — une commande se relit donc même sans la feature qui l'a produite.
Réordonner sans toucher au code
Le projet final réécrit les priorités en config, sans modifier les packages :
php
// config/pricing.php
return [
'priorities' => [ App\Pricing\FideliteePriceModifier::class => 40 ],
'totals_priorities' => [ /* … */ ],
];Remplacer l'orchestration entière
Ajouter un maillon couvre l'immense majorité des cas. Si vous devez changer l'orchestration elle-même — l'algorithme qui assemble le prix, les totaux ou le placement de commande — chacun de ces orchestrateurs est résolu derrière un contrat substituable, comme n'importe quel service :
| Contrat | Rôle | Défaut |
|---|---|---|
PricePipeline | calcule le prix décomposé d'un produit | DefaultPricePipeline |
TotalsBuilder | assemble les lignes de totaux (forCart, forOrder) | DefaultTotalsBuilder |
OrderPlacementPipeline | place une commande à partir d'un panier | DefaultOrderPlacementPipeline |
php
use Slab\Framework\Core\Pricing\Contracts\PricePipeline;
$this->app->bind(PricePipeline::class, MonPricePipeline::class);C'est le même geste que pour n'importe quel service du cœur : un plugin le fait pour un comportement réutilisable sur plusieurs projets, la couche applicative du skeleton pour du sur-mesure ponctuel. Pour le placement, on réagit d'ordinaire via OrderPlaced ; ne remplacez le pipeline que pour changer le déroulé lui-même.
Aller plus loin
- Le détail tarifaire (taxe, devise, commande figée) : domaine Pricing & taxe.
- Les contrats exacts : référence des points d'extension.