Apparence
Panier
Le panier porte les lignes produit, la famille de contexte (devise / zone fiscale) et les choix de checkout. Sa logique de contexte et ses événements sont les principaux leviers d'extension.
Le modèle
Cart est polymorphe : il appartient à un User ou à un Guest (relation cartable(), morphTo('model')). Ses lignes sont des produits via un pivot (quantity, is_gift). Les choix du tunnel (transporteur, paiement…) sont stockés dans une sélection générique checkout_selections (JSON, de forme { requirement: { option_id, data } }, lue et écrite par selectCheckoutOption(), selectedCheckoutOption() et selectedCheckoutOptionId()) — pas de colonne par feature. La fusion d'un panier invité vers un compte émet CartMerged.
Domaine vs contexte : deux rôles distincts
| Classe | Rôle |
|---|---|
CartService | opérations sur un panier explicite : addProduct, updateProduct, deleteProduct, merge, createOrder. Chaque mutation annonce d'abord son intention par un événement annulable, puis émet son constat ; les trois mutations de ligne rendent un bool (false = l'écriture n'a pas eu lieu). Sans session. |
CurrentCartResolver | contexte : resolve() répond « quel est le panier courant ? », remember(Cart $cart) le retient d'une page à l'autre une fois enregistré, et setOnLogin(Cart $guestCart) gère la transition à la connexion. Ces trois méthodes suivent la stratégie choisie (B2C par défaut : compte connecté, sinon cookie). |
CartPriceService | totaux produits d'un panier donné : getTotalProductsPrice(bool $ti = false): Money. Scopé au panier, pas au contexte. |
C'est la séparation domaine / contexte : CartService agit sur un panier qu'on lui donne ; CurrentCartResolver décide lequel dans la requête.
Les implémentations par défaut (DefaultCartService, DefaultCartPriceService, DefaultCurrentCartResolver) sont final et liées au contrat dans CoreServiceProvider : on les remplace dans le container, on n'en hérite pas.
DefaultCurrentCartResolver est la stratégie B2C : panier du compte connecté, sinon panier invité identifié par le cookie shop.cookies.cart (cart_id par défaut). Un panier ne s'enregistre qu'avec son premier produit : chaque page lit le panier (quantité de l'entête, zone fiscale des prix), et l'enregistrer dès la lecture en créerait un par visiteur, robots d'indexation compris. Qui n'a pas encore de panier en reçoit donc un vide, en mémoire : new Cart pour un invité, carts()->make() pour un compte, auquel il est déjà rattaché. On peut le lire, mais il n'existe pas en base : un consommateur de resolve() ne doit pas supposer que le panier a une clé. Le résolveur est scopé par requête et mémorise sa résolution sur l'instance, sans aucun état statique partagé entre process.
Une fois le panier enregistré, remember($cart) le retient pour les pages suivantes, à condition que ce soit l'instance résolue (comparaison stricte). Pour un invité, le résolveur met le cookie en file (Cookie::queue()). Un panier de compte se retrouve par le compte et n'a besoin de rien. Le cœur appelle remember() à la création de tout panier (écouteur RememberNewCart sur eloquent.created), pas seulement lors d'un ajout de produit : quel que soit le geste qui a fait naître le panier, il ne se perd pas.
À la connexion, setOnLogin() bascule sur le panier du compte. Il n'y fusionne le panier invité que si le panier du compte est vide (total_quantity <= 0) ou n'existe pas. Dans ce second cas, il prend un panier de compte en mémoire, qui ne s'enregistre que si la fusion lui apporte un produit.
Hors requête
FixedCurrentCartResolver impose un panier connu (new FixedCurrentCartResolver($cart)) : utile en test ou dans un job, où il n'y a ni cookie ni utilisateur authentifié. setOnLogin() y rend simplement le panier fixé, et remember() n'y fait rien.
Les mutations : intention, garde-fous, constat
Avant toute écriture, le service émet un événement annulable par la statique propose() du trait Cancellable (CartItemAdding::propose($cart, $product, $quantity)) et vérifie isCancelled() : si un écouteur s'y est opposé — $event->cancel('motif') —, la méthode rend false sans rien modifier et le constat n'est pas émis. Le premier motif donné est conservé et relisible via reason().
| Intention (annulable) | Constat | Charge utile |
|---|---|---|
CartItemAdding | CartItemAdded | cart, product, quantity |
CartItemUpdating | CartItemUpdated | cart, product, quantity |
CartItemRemoving | CartItemRemoved | cart, product |
| — | CartMerged | cart, source |
Six comportements du service par défaut méritent d'être connus avant d'écrire un écouteur :
- Commandabilité :
addProductrefuse d'emblée un produit dontis_orderableest faux (statut commandable et disponibilité commandable → Catalogue), avant tout événement.updateProductapplique la même règle de façon asymétrique : sur un produit devenu incommandable, on peut réduire la quantité ou retirer la ligne, jamais en demander davantage. - Panier encore en mémoire :
addProductest la seule méthode qui enregistre un panier absent de la base ($cart->existsfaux). Il le fait juste avant d'attacher la ligne, donc aprèsCartItemAdding: l'écouteur peut recevoir un panier sans clé, et un veto n'enregistre rien. Sur un tel panier,updateProductetdeleteProductrendentfalsed'emblée et n'émettent aucun événement. - Ajout d'une ligne existante :
addProductsur un produit déjà au panier cumule la quantité en déléguant àupdateProduct. Ce sont alorsCartItemUpdating/CartItemUpdatedqui sont émis, pasCartItemAdding/CartItemAdded. Un écouteur qui compte les ajouts doit écouter les deux. - Quantité nulle :
updateProductavec une quantité<= 0délègue àdeleteProduct, et donc àCartItemRemoving/CartItemRemoved. - Date du panier : chaque écriture date le panier (
$cart->touch()) juste avant d'émettre le constat. Sa date de mise à jour indique donc la dernière fois qu'on y a touché, ce que l'écriture d'une ligne ne fait pas à elle seule. C'est ce qui permet de reconnaître un panier abandonné. Un refus (veto ou produit incommandable) laisse cette date intacte. - Valeur de retour : pour
updateProductetdeleteProduct, leboolrendu est celui de l'écriture du pivot. Unfalsepeut donc signaler un veto, un panier encore en mémoire, une ligne absente ou une quantité déjà à cette valeur. Dans ces deux derniers cas, le constat a déjà été émis :falsene prouve pas qu'un écouteur s'est opposé.
merge($into, $from) reporte chaque ligne source dans la cible, et la quantité y est fixée à celle de la source : elle ne s'additionne pas. Le chemin dépend de la cible. Une ligne qu'elle ne portait pas est ajoutée (CartItemAdding / CartItemAdded), une ligne déjà présente voit sa quantité fixée (CartItemUpdating / CartItemUpdated). Chaque ligne passe donc par les mêmes garde-fous (commandabilité, événement annulable) : une ligne refusée ne bloque pas les autres. CartMerged est émis à la fin, et la méthode rend le panier cible.
Les totaux produits
CartPriceService est résolu avec son panier en paramètre :
php
use Slab\Framework\Core\Cart\Contracts\CartPriceService;
$service = app(CartPriceService::class, ['cart' => $cart]);
$ht = $service->getTotalProductsPrice(); // Brick\Money\Money
$ttc = $service->getTotalProductsPrice(true);Chaque prix unitaire est déjà passé par le PricePipeline (ProductPriceService::getMoney()) : le total est la somme exacte prix × quantité, composée dans shop.default_currency_iso. Le cœur ne connaît ici aucun plugin.
Ce prix est celui du titulaire du panier, jamais celui de la personne connectée : le service lie chaque ProductPriceService à $cart->pricingCustomer() par forCustomer() (Catalogue). pricingCustomer() rend le compte du titulaire si le panier appartient à un User, et null (les prix publics) pour un Guest. Un panier consulté depuis le back-office ou un job garde donc les montants de son client, pas ceux de l'administrateur qui le lit.
Le total complet du panier (produits plus toutes les lignes contribuées, comme la livraison) est assemblé par TotalsBuilder, jamais par ce service : Pricing & taxe. Le modèle l'expose sous $cart->totals (TotalsBuilder::forCart()). $cart->total_products_price_te/_ti et $cart->total_price_te/_ti n'en sont que des façades scalaires arrondies, pour le front.
Par où l'étendre
| Je veux… | Point d'extension | Mécanique |
|---|---|---|
| changer « quel est le panier courant » et comment on le retrouve d'une page à l'autre | CurrentCartResolver (bind) | résolveurs |
| imposer un panier hors requête (test, job) | FixedCurrentCartResolver | résolveurs |
| changer la devise / la zone courantes | CurrentCurrencyResolver / CurrentAreaResolver | résolveurs |
| remplacer le calcul du total produits | CartPriceService (bind) | pipeline |
| ajouter une ligne aux totaux | TotalsModifier | pipeline |
| réagir aux changements du panier | CartItemAdded / Updated / Removed, CartMerged | événements |
| empêcher une modification (stock, quota, ligne offerte) | CartItemAdding / Updating / Removing — événements annulables (CancellableEvent) | événements |
greffer une relation sur Cart | resolveRelationUsing | modèles |
Exemple : réagir à un ajout au panier
php
use Illuminate\Support\Facades\Event;
use Slab\Framework\Core\Cart\Events\CartItemAdded;
Event::listen(CartItemAdded::class, function (CartItemAdded $event) {
// $event->cart, $event->product, $event->quantity — analytics, suggestions…
});WARNING
CartItemAdded n'est émis que pour une nouvelle ligne. Un réapprovisionnement de ligne existante passe par CartItemUpdated.
Du panier à la commande
CartService::createOrder(Cart $cart, ?OrderStatus $order_status = null, ?Currency $currency = null) délègue à l'OrderPlacementPipeline (transactionnel, idempotent), qui fige la commande. Détail : Commande & checkout.
Voir aussi
- Les résolveurs — le contexte devise/zone/panier.
- Les événements · Pricing & taxe.
- Points d'extension — la liste complète des contrats du cœur.