Skip to content

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œur

C'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 :

ContratRôleDéfaut
PricePipelinecalcule le prix décomposé d'un produitDefaultPricePipeline
TotalsBuilderassemble les lignes de totaux (forCart, forOrder)DefaultTotalsBuilder
OrderPlacementPipelineplace une commande à partir d'un panierDefaultOrderPlacementPipeline
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 ​