Skip to content

Modèles & relations ​

Vos données (un programme de fidélité, des transactions, des avis…) doivent se relier aux modèles du cœur — Product, Cart, Order… — sans que le cœur connaisse vos modèles. La mécanique centrale est resolveRelationUsing — et, pour un simple champ, le ModelExtensionRegistry.

Le problème ​

Le package « promotions » a une table promotions et veut écrire $product->promotions. Mais Product est dans le cœur, qui n'importe jamais Promotion. Comment ajouter une relation à un modèle qu'on n'a pas le droit de modifier ?

Greffer une relation ​

Au boot() du provider, on ré-attache la relation depuis l'extérieur — l'inverse exact du couplage cœur → feature :

php
use Slab\Framework\Models\Product;

Product::resolveRelationUsing('promotions',
    fn (Product $product) => $product->belongsToMany(Promotion::class));

L'appel comme méthode ($product->promotions()) et l'accès propriété ($product->promotions) fonctionnent. Tous les types de relations Eloquent sont possibles :

php
Order::resolveRelationUsing('transactions',
    fn (Order $order) => $order->hasMany(Transaction::class));

TaxRule::resolveRelationUsing('carriers',
    fn (TaxRule $rule) => $rule->hasMany(Carrier::class));

// La table pivot `cart_promotion` vient de la migration du package, pas du cœur.
Cart::resolveRelationUsing('promotions',
    fn (Cart $cart) => $cart->belongsToMany(Promotion::class));

C'est ainsi que slab/promotion, slab/payment, slab/carrier et slab/invoicing se branchent sur le cœur. Le mouvement vaut aussi pour les relations que le cœur a retirées en se découplant : OrderStatus ne connaît plus les moyens de paiement, un package qui en a besoin greffe la relation lui-même.

La relation n'est pas toujours la bonne réponse, cela dit : ce que le client choisit au tunnel — son transporteur, son moyen de paiement — ne tient dans aucune colonne du panier, mais dans sa sélection de checkout, un emplacement générique du cœur décrit plus bas. Aucun package du cœur n'ajoute d'ailleurs de colonne à une table du cœur : ils posent leurs propres tables, et se raccrochent par une relation greffée ou par ces emplacements.

Contribuer un attribut ​

Parfois votre feature n'ajoute pas une table mais un champ : un GTIN, une accroche traduite, une image. Or un modèle Eloquent déclare sa surface dans des propriétés protégées — $fillable, $casts, $translatable, $contentable — hors de portée d'un package. Le ModelExtensionRegistry ouvre cette surface :

php
use Slab\Framework\Core\Model\Contracts\ModelExtensionRegistry;
use Slab\Framework\Models\Product;
use Stafe\LaravelContentable\Models\Image;

$this->app->make(ModelExtensionRegistry::class)->extend(
    Product::class,
    fillable: ['gtin'],
    casts: ['weight' => 'decimal:3'],
    translatable: ['claim'],
    contentable: ['short_description', 'main_image' => Image::class],
);

Les modèles ouverts — Product, les taxonomies (Category, qui étend la base abstraite Taxonomy), le couple Attribute / AttributeValue, User, Address, Cart, Order, OrderStatus et la page d'accueil (Contents\HomePage) — portent le trait HasContributedAttributes : il fusionne vos contributions dans getFillable(), getCasts(), getTranslatableAttributes() et getContentable(). Les déclarations du cœur survivent, les vôtres s'y ajoutent. Le choix est délibéré : d'un attribut, le cœur ne connaît que son nom et sa place dans l'arbre ; type, unité, icône, ordre, rôle, code couleur d'un nuancier appartiennent au catalogue qui l'utilise. Même partage sur le compte : le cœur d'un User tient à son identité et à ses préférences, les champs propres à une clientèle — ceux qu'une couche B2B pose sur ses clients — se contribuent. Même mouvement sur ce que le tunnel recueille en plus : la société et le numéro de TVA d'un acheteur professionnel se posent sur Address, la contremarque d'un marché sur Cart et sur Order, la case « Émettre la facture » sur l'OrderStatus qui, dans cette maison, veut dire « c'est facturé » — slab/invoicing y contribue issues_invoice, sa règle de validation et sa section d'écran allant de même par les registres. Le cœur n'a à nommer aucun de ces champs.

Un contenu contribué devient assignable et suit le cycle de vie complet — écriture en cascade au save(), suppression avec le modèle ; une entrée sans clé désigne un texte traduisible (TranslatableText), et une valeur en tableau ('items' => [MenuItem::class], comme sur Menu) désigne une collection de contenus.

C'est par là qu'un thème pose ses parties sur la page d'accueil : HomePage est un contenu racine — ni table ni migration —, dont le cœur ne connaît qu'une partie, le bandeau ('banner' => Banner::class). Contribuer 'mosaic' => Mosaic::class y ajoute la vôtre, que votre package remporte le jour où on le retire ; comme toute classe de contenu, elle a besoin de son alias de morph (voir plus bas).

Le schéma reste le vôtre

Contribuer gtin au modèle ne crée pas la colonne : la migration vit dans votre package (loadMigrationsFrom, voir Créer un package). Un contenu contentable, lui, ne demande aucune migration — il vit dans la table contents.

TIP

HasContributedAttributes embarque HasContents et HasTranslations : un modèle qui l'utilise ne doit pas les reprendre lui-même (conflit de trait). Et un attribut contribué n'est pas cherchable pour autant : il doit aussi entrer dans le document d'index via le SearchIndexRegistry — voir Recherche.

Créer sa propre taxonomie ​

Category n'est qu'une implémentation : la base abstraite Slab\Framework\Models\Taxonomy — arbre imbriqué (NodeTrait), name/slug traduisibles — est ouverte à vos propres axes de classement (marques, collections…). Toutes les taxonomies partagent la table taxonomies : le constructeur force la colonne discriminante taxonomy à static::class, et un scope global filtre chaque requête sur cette valeur. Vous n'avez donc aucune migration à écrire pour la taxonomie elle-même.

php
namespace Acme\Catalog\Models;

use Slab\Framework\Models\Taxonomy;

class Brand extends Taxonomy
{
    //
}

Vous héritez de getRoot() (crée à la demande la racine du sous-arbre, avec un slug dérivé du nom de classe), du scope notRoot() et de getFlatTreeList() — l'arbre à plat, indenté par niveau (id => '- Perceuses') et racine technique exclue — pour les selects et filtres hiérarchiques du back-office. Comme Product, une taxonomie porte HasContributedAttributes : descriptions, visuels ou statut se contribuent. Relier votre taxonomie aux produits, en revanche, demande une table pivot possédée par votre package et une relation greffée sur Product (voir plus haut) — le pivot CategoryProduct, ordonné et observé, montre le patron si le vôtre doit l'être aussi.

Ce que Category ajoute par-dessus lui est propre : son url() (elle implémente RedirectableBySlug), des fichiers qui portent son nom (elle implémente NamesStoredFiles, voir plus bas) et un accesseur filters calculé — les filtres qu'elle a de quoi proposer sont lus de l'index par CategoryService::availableFilters(), ce n'est plus une colonne JSON. Ils sont donc toujours à jour, sans réindexation à attendre ; l'accesseur les mémorise sur l'instance, mais chaque première lecture interroge le moteur : ne l'appelez pas en boucle sur une liste de catégories. Voir Recherche.

WARNING

La colonne taxonomy stocke le FQCN de la classe, pas un alias : contrairement aux model_type de la morph map, renommer ou déplacer votre classe rend les lignes existantes invisibles au scope global.

Les emplacements génériques ​

Le cœur ne possède aucune colonne par feature. Quand la donnée n'appartient ni à une table à vous ni à un attribut du modèle, il expose des emplacements génériques, en JSON :

  • carts.checkout_selections — le choix du client pour une exigence du tunnel (livraison, paiement…) parmi les options agrégées par le CheckoutOptionRegistry, écrit — persisté aussitôt, totaux mémoïsés oubliés dans la foulée — par selectCheckoutOption(), effacé de même par forgetCheckoutOption() quand l'option ne dessert plus le panier (un transporteur éteint, une adresse qu'il ne livre pas : garder le choix ferait compter au récapitulatif un port que personne ne facturera), relu par selectedCheckoutOption() / selectedCheckoutOptionId(). Forme : { requirement: { option_id, data } }.
  • orders.checkout_selections — les mêmes choix, figés sur la commande au placement.
  • orders.price_components — le snapshot des lignes de totaux contribuées (livraison, promotions…), figées elles aussi au placement, à côté des colonnes de montants que la commande fige dans les deux devises (total_products_price_te, total_price_ti, currency_total_price_ti…).
php
$cart->selectCheckoutOption('shipping', (string) $carrier->id, ['pickup_point' => 'FR-12345']);

$cart->selectedCheckoutOptionId('shipping'); // '12'

$cart->forgetCheckoutOption('shipping'); // le transporteur ne livre plus cette adresse

Côté lecture, $cart->totals et $order->totals délèguent au TotalsBuilder (forCart() / forOrder()) ; les accesseurs scalaires du panier ne sont que des façades — total_products_price_te / _ti sur le CartPriceService, total_price_te / _ti sur la ligne total des totaux (exclTax / inclTax). Le cœur n'additionne pas lui-même la livraison, il lit la ligne assemblée ; l'arrondi à l'échelle de la devise n'a lieu qu'à ce bord.

Les totaux se mémorisent dans l'instance

Eloquent garde la valeur d'un accesseur qui rend un objet, et totals en est un : lus une fois, les totaux ne bougent plus de la requête. selectCheckoutOption() et forgetCheckoutOption() les oublient déjà pour vous (Cart::forgetTotals()) ; appelez-le vous-même après toute autre écriture qui pèse sur les montants — le choix d'une adresse de livraison, dont dépend la zone tarifaire —, sans quoi un tunnel qui change de transporteur puis rend son récapitulatif dans la même requête afficherait le port d'avant sous le mode d'après.

Voir Commande & checkout et Pricing & taxe.

Modèles polymorphes : possédés par plusieurs entités ​

Certains modèles du cœur (Cart, Order, Address, Consent) appartiennent à plusieurs types via une relation morphTo — un User ou un Guest aujourd'hui. Les colonnes sont model_type / model_id, mais chaque relation se déclare sous son propre nom : $this->morphTo(__FUNCTION__, 'model_type', 'model_id'), dans cartable(), orderable(), addressable(), consentable(). Le raccourci morphTo('model') nommerait la relation d'après ses colonnes : with('orderable') la chargerait alors sous deux noms, dont le bon à null — une commande sans client, sans la moindre erreur. Côté inverse, 'model' reste correct : l'argument y désigne les colonnes de l'enfant, pas une relation ($user->morphMany(Cart::class, 'model')).

Les deux relations de propriété lisent en outre la corbeille : Order::orderable() et Cart::cartable() sont withTrashed(). Désactiver un compte, c'est le supprimer de façon récupérable (voir plus bas) — sans cela, ses commandes passées et son panier abandonné paraîtraient sans client, à l'écran comme au listing, sans la moindre erreur.

Une variante filtrée contraint la relation voisine, jamais elle-même : Cart::guest() est un morphTo restreint par whereHasMorph('cartable', Guest::class) — se nommer soi-même ferait tourner la requête sur elle-même jusqu'à épuiser la mémoire.

Pour la question inverse — « à qui appartient cet objet ? » —, Cart::isBelongingTo(Guest::class) compare la classe du propriétaire et rend false s'il n'y en a pas. Cart::pricingCustomer() en tire le compte dont le tarif s'applique au panier : son titulaire s'il est authentifiable (un User), null — les prix publics — pour un invité ou un panier sans propriétaire. Jamais la personne connectée : sur le site les deux se confondent, mais un panier lu du back-office, par une IA ou une relance montrerait sinon les montants de celui qui le lit. Un package qui chiffre un panier part donc de lui, pas de auth()->user() (voir Panier). Et traitez les deux types à égalité : Guest porte Notifiable et HasLocalePreference (preferredLocale()), rend sa devise par preferredCurrency, et possède contenus, consentements, panier et commande comme un compte — un package qui ne sert que User laisse la moitié du tunnel de côté. Son fullname, comme celui d'un compte, ne joint que le prénom et le nom renseignés : un invité sans identité — une commande passée avant que le tunnel ne la demande — rend une chaîne vide au lieu de faire tomber le classement ou l'écran qui le lit. À sa création, il se pose lui-même une langue et une devise — celle du moment, celle de la boutique (shop.default_currency_iso) — plutôt que de laisser jouer les identifiants en dur du schéma, qui faisaient tomber le tunnel invité sur une contrainte de clé étrangère.

La morph map est posée en mode strict (Relation::enforceMorphMap(), dans AppServiceProvider::enforceMorphAliases()) : les model_type stockent des alias stables (user, guest, product, category, attribute, attribute_value, address, order, page, order_status, price, tax, tax_rule, currency, language, menu, menu_item, la page d'accueil et ses parties, plus les types de contenus file, gallery, image, link et le texte traduisible translatable_text) au lieu de FQCN, pour survivre aux renommages. Ces alias servent aussi un package qui désigne un modèle du cœur par un morph — un journal, une proposition, un document. Conséquence pour un package : tout modèle persisté polymorphiquement — propriétaire de contenus et classe de contenu (une partie de la page d'accueil, par exemple) compris — doit y figurer, sinon getMorphClass() lève une ClassMorphViolationException. Ajoutez le vôtre au boot() — Laravel fusionne les entrées par défaut :

php
use Illuminate\Database\Eloquent\Relations\Relation;

Relation::enforceMorphMap(['loyalty_account' => LoyaltyAccount::class]);

C'est aussi ainsi qu'une feature ajoute son propre type de propriétaire, sans toucher au cœur.

Étendre le comportement d'un modèle ​

Au-delà des relations, les modèles du cœur sont conçus pour être enrichis :

  • Traits : Activable (scope ->active() — ->active(false) pour l'inverse —, plus activate() / deactivate() / isActive() et la colonne redéfinissable par activeColumn() : c'est elle que lisent la colonne de listing et les actions groupées d'activation), SoftDeletes (celui du cœur ajoute replicateSoftDelete() à celui de Laravel), HasTranslations (champs multilingues JSON), HasContents (attributs éditoriaux), HasContributedAttributes (les deux précédents, plus les contributions des packages), Searchable (indexation) — réutilisables sur vos propres modèles. Sur User, c'est SoftDeletes qui porte la désactivation d'un compte : le désactiver, c'est le supprimer de façon récupérable — il ne peut plus se connecter et sort des listes, sans que ses commandes ni son historique perdent leur client (le back-office en parle en « actif / inactif », AccountStatus). Un compte porte un second état, qu'on ne confondra pas avec celui-là : son inscription est validée ou non — la date approved_at, que lit User::isApproved(), et le scope User::pendingApproval() pour les demandes qui attendent un administrateur. Les deux ne se recouvrent pas : un compte peut n'avoir jamais été validé, un autre avoir servi des années avant d'être désactivé (voir Comptes & autorisations). D'où une règle tenue partout dans le cœur : un compte désactivé garde ce qu'il a signé — Order::orderable(), Cart::cartable(), Page::author() et OrderNote::author() lisent la corbeille. Un package qui pointe à son tour vers un compte, ou qui doit rester capable de le relire — une fiche, un export —, demande donc withTrashed() de même, comme la commande le fait aussi pour sa devise. Cas voisin, et pas une tolérance : les adresses figées de la commande — Order::deliveryAddress() et Order::invoiceAddress() — sont des copies que le placement crée déjà supprimées (replicateSoftDelete()), pour qu'elles ne reparaissent jamais dans le carnet du client ; sans withTrashed(), la relation ne résoudrait rien et toute commande s'afficherait « adresse non renseignée », sans la moindre erreur. La copie emporte aussi les contenus du modèle, répliqués un à un : replicate() ne recopie que les colonnes, et l'instantané d'une adresse en était une coquille — sans rue, sans code postal ni nom.
  • Attributs contentables : ajoutez des champs riches/traduits sans migration via HasContents (Guest, Page), ou depuis un package via le registre ci-dessus (Product, Category, Attribute, AttributeValue, User, Address, Cart, Order, OrderStatus, HomePage). Page montre jusqu'où cela va : tout son éditorial — summary, content, meta_title, meta_description, keywords, image — est du contenu, la table ne gardant que l'adressage (slug et name, traduisibles) et ce qui se filtre et se trie (is_active, published_at, l'auteur). Address va plus loin encore : libellé, prénom et nom, rue, complément, code postal, ville et téléphone sont tous des contenus, la table ne gardant que le pays (area_id) et le propriétaire ; son accesseur name compose le libellé de liste — le libellé d'abord quand il y en a un, la rue toujours ensuite, puis le code postal et la ville, et chaque membre facultatif, une saisie en cours n'ayant pas à faire tomber l'écran.
  • Fichiers nommés d'après le modèle : Product et Category implémentent Stafe\LaravelContentable\Contracts\NamesStoredFiles par le trait Slab\Framework\Models\Traits\NamesFilesAfterName — une image envoyée s'appelle 107-patrix-fauteuil-en-chene-massif-fr.jpg plutôt que 107_fr.jpg. Le trait fournit storedFileLabel($locale) (le name dans la langue du fichier, null s'il est vide, ce qui garde le nom simple) et, quand le nom change, renomme les fichiers déjà stockés (renameStoredFiles(), de HasContents) après la validation de la transaction (DB::afterCommit()), jamais pendant : un enregistrement annulé laisserait des fichiers déplacés que la base ne pointe plus. Il se réutilise sur un modèle qui porte HasContents et un name traduisible. À savoir avant de l'adopter : renommer le modèle change l'adresse de ses fichiers, et les anciennes répondent 404 — un cache, un e-mail déjà parti, une image déjà indexée.
  • Observers : les modèles du cœur déclarent les leurs par attribut (#[ObservedBy] — ProductObserver, CategoryObserver, CategoryProductObserver, AttributeObserver, AttributeValueObserver, LanguageObserver, PriceObserver, UserObserver) ; votre package enregistre les siens en plus. Ils tiennent aussi ce qu'une suppression laisse derrière elle : supprimer une langue fait passer les comptes (désactivés compris) et les invités qui la parlaient à la langue par défaut (Language::default()), la clé étrangère refusant désormais de supprimer une langue encore parlée ; supprimer la déclinaison par défaut d'une famille lui en rend une (Product::restoreDefaultVariant()). Pour refuser une suppression, motif à l'appui, n'annulez pas deleting, qu'Eloquent annule en silence : écoutez ModelDeleting (voir le back-office). C'est par les observers que passe aussi l'indexation : Product surcharge bootSearchable() pour désactiver la synchro automatique de Scout au profit des observers — la surcharge ne garde que le SearchableScope et diffère les macros de Scout à whenBooted(), instancier un modèle pendant son propre boot levant une LogicException depuis Laravel 13. Voir Les événements.
  • Document d'index : Product::toSearchableArray() rend un document par produit — une déclinaison porte ses propres attributs, prix et disponibilité, plus le nom, les catégories (ascendance comprise) et les caractéristiques de sa famille ; family_id regroupe la famille, variant_rank dit laquelle la représente à égalité de tri. Un parent à déclinaisons, lui, n'entre pas dans l'index — ses déclinaisons visibles y prennent sa place —, mais son document d'union alimente les filtres de catégorie. Le document se termine par les champs contribués (SearchIndexRegistry::documentFor($this)), et les listes d'attributs cherchables, filtrables, triables et de facettes (Product::searchableAttributes() et ses sœurs) viennent du même registre. Un package qui indexe à son tour une donnée doit la calculer sur le même périmètre que le champ auquel elle répond — deux périmètres, publics et mémorisés pour ça : Product::ownProducts() (les déclinaisons visibles, ou le produit seul) porte ce qui décline — attributs, prix, disponibilité —, Product::describedFamily() y ajoute le parent qui décrit la famille et porte les caractéristiques. Une donnée qui se compte par famille — la popularité — se réindexe sur toute la famille, Product::familyIds() en donnant les identifiants. Le resolver qu'il déclare au SearchIndexRegistry ne peut pas relire toSearchableArray() : c'est elle qui l'appelle, il se rappellerait sans fin. À la lecture, en revanche, searchableDocument() mémorise le document sur l'instance — les sources de filtres d'une catégorie le lisent chacune à leur tour —, quand toSearchableArray(), contrat de Scout, le rebâtit à chaque appel. Contribuer un champ suffit presque toujours ; sur vos modèles, la méthode reste la vôtre. Voir Recherche.
  • Scopes : le cœur déclare ses scopes avec l'attribut #[Scope] (Product::visible(), orderable(), byStatus(), forListing(), Page::visible(), AttributeValue::byAttribute(), Taxonomy::notRoot(), Currency::byIso(), Area::byIso(), Language::byLocale(), User::withRole(), User::pendingApproval()) plutôt qu'avec le préfixe scopeXxx ; suivez la même convention dans vos modèles (voir les scopes locaux de la doc Laravel). Un scope porte une règle, pas un raccourci : Page::visible() croise le drapeau d'activation et published_at (nulle ou échue — une page se prépare et prend effet au jour dit), et Page::isVisible() répond la même chose sur une instance, pour que le front, le menu et vos propres consommateurs disent tous « publiée » au même moment.
  • Listes rangées par nom traduit : Area::alphabetical() rend toutes les zones dans l'ordre alphabétique de la langue de la page (App::getLocale()), par le collateur d'ICU (Collator) — celui d'une liste de pays qu'on parcourt des yeux. La base les rangeait par code ISO (« Andorre, Émirats arabes unis, Afghanistan… »). Un tri PHP SORT_LOCALE_STRING range bien les lettres accentuées, mais dans la seule langue que LanguageServiceProvider donne au processus (LC_COLLATE à fr_FR.utf8), et pourvu que le système l'ait installée : hors de l'application, sous la locale « C », « Émirats » passe après « Zimbabwe ». Le collateur, lui, suit la langue de la page. Le tri se fait en PHP, sur toutes les lignes : il convient à une liste de référence, pas à une table qui grossit. Rangez de même les noms traduits de vos propres listes.
  • URL & slug : un modèle dont l'URL publique porte un slug (Product, Category, Page) implémente Slab\Framework\Models\Interfaces\RedirectableBySlug — un seul membre, l'accesseur url(): CastAttribute. Le middleware SlugRedirect s'en sert sur les routes en {modèle}-{slug} : si le slug de l'URL ne correspond plus à celui du modèle, il redirige en 301 vers $model->url. L'accesseur peut rendre null : une famille de Product n'a pas de page à elle et mène à la déclinaison qui la représente (Product::landingVariant() — la déclinaison par défaut si elle se montre, sinon la première visible) ; quand aucune ne se montre, la famille n'a pas d'adresse, le middleware ne redirige pas et la page répond 404. Un consommateur de url doit donc prévoir ce cas. Pour un modèle hors du cœur, liez-le dans la route (route model binding) : le middleware ne résout lui-même que les classes de Slab\Framework\Models.
  • États ouverts : products.status reste une chaîne en base, mais ProductStatusCast la traduit en objet ProductStatus pris au ProductStatusRegistry — le statut qu'ajoute un package est donc un statut comme les autres (key, label, visible, orderable, priority), sans migration. Les accesseurs is_visible / is_orderable lisent les drapeaux de cet objet (visible, orderable), is_orderable y ajoutant $product->availability->orderable ; les scopes visible() / orderable(), eux, interrogent le registre (visibleKeys(), orderableKeys()) au lieu de comparer à « en ligne ». $product->availability vient du contrat ProductAvailabilityService (for($product)), substituable (WMS, ERP, multi-dépôt) : il rend un ProductAvailability — state (chaîne libre, in_stock / low_stock / out_of_stock par défaut, avec son label()), orderable, available, message. Les colonnes que le cœur porte pour l'alimenter (stock_physical, stock_reserved, stock_threshold, allow_backorder, et le message traduisible out_of_stock_message) restent des données brutes : c'est le service qui en tire une décision. $product->stock_available, lui, se déduit des deux premières (stock_physical moins stock_reserved, null quand le stock n'est pas suivi). Même esprit pour la nouveauté, qui n'est ni un statut ni une catégorie mais une date : $product->is_new compare created_at à la fenêtre shop.catalog.novelty_window (en jours, zéro ou vide éteignant la notion), et un produit cesse d'être neuf sans que personne y repasse — le cœur ne dit pas pour autant comment cela se montre, la pastille appartient au thème. Voir Catalogue.
  • Statuts de commande : OrderStatus est de la donnée administrée, pas un enum — une boutique invente les siens. Ce que le domaine en sait tient dans des drapeaux non exclusifs (is_validated, is_shipped, is_delivered, is_paid, is_cancelled, locks_order, is_hidden_from_customer, notifies_admin), et la commande date les étapes franchies (validated_at, shipped_at, delivered_at, paid_at, cancelled_at, locked_at), en gardant par quoi le verrou est tombé — Order::lockedByStatus(), tant que ce statut existe. La liste n'est pas fermée : le modèle porte HasContributedAttributes, et un package y pose le drapeau que le cœur n'a pas à connaître — slab/invoicing y contribue issues_invoice, coché sur le statut auquel la boutique facture. Les quatre premiers drapeaux décrivent un avancement, is_cancelled un arrêt : sans lui, une commande abandonnée se lit comme une commande qui n'a pas encore été expédiée, et tout ce qui compte les commandes en cours compte les annulations avec. Et livrée n'est pas expédiée : l'une dit que la commande est arrivée, l'autre seulement qu'elle est partie — sans les deux, un écran qui veut les distinguer en serait réduit à reconnaître les statuts à leur nom, alors qu'ils sont de la donnée administrée. Les dates, elles, sont des cliquets : posées à la première fois, jamais réécrites — remettre une commande annulée en préparation ne l'efface pas de son histoire. Deux relations lisent l'historique : Order::status() — le dernier enregistré, départagé par id à created_at égal — et Order::visibleStatus(), le dernier qui ne soit pas masqué au client : c'est celui-là, jamais status(), que lit le compte client ; une ligne dont le statut a depuis été supprimé y reste visible. Chaque ligne d'OrderStatusHistory recopie le nom appliqué — un instantané, qu'un statut renommé ne réécrit pas — et garde orderStatus() pour lire ce que ce statut signifie aujourd'hui : ses drapeaux, sa couleur. Celle-ci est libre (color), d'où OrderStatus::badgeStyle(), qui rend le style en ligne de la pastille et y calcule une encre lisible sur le fond choisi. Voir Commande & checkout.
  • Ce qui s'écrit sur une commande : deux textes, qui ne se confondent pas. Celui du client — customer_comment, saisi au panier puis recopié figé sur la commande au placement. Ceux de l'équipe — Order::notes(), les OrderNote la plus récente en tête : un journal où rien ne s'écrase, jamais montré au client, et dont author() se lit withTrashed() — un compte désactivé garde ce qu'il a signé, seul un effacement définitif laisse la note sans nom (elle lui survit). Une ligne vendue, elle, ne dépend pas du catalogue : OrderProduct porte son nom, sa référence et ses montants figés — plus le drapeau is_gift, que le pivot du panier porte déjà à côté de la quantity —, et OrderProduct::product() ne sert qu'à rouvrir la fiche si le produit existe encore. La devise, elle aussi, se relit withTrashed() : retirer une devise du front ne doit pas rendre illisibles les commandes passées avec elle.
  • Écritures qui ne se réécrivent pas : OrderStatusHistory et Price posent const UPDATED_AT = null — ce sont des faits datés, et une trace qu'on peut modifier ne trace plus rien. Faites de même pour vos journaux et vos historiques. Le prix en vigueur d'un produit est donc le dernier de cet historique ($product->price), départagé par id à created_at égal, comme Order::status() : deux prix posés dans la même seconde ne laissent plus gagner l'ancien. Quand une liste a préchargé les prix (Product::forListing(), voir Catalogue), l'accesseur les départage en mémoire, dans le même ordre, sans requête. Et cet accesseur se mémorise dans l'instance, comme les totaux du panier : ProductPriceService::addPrice() l'oublie déjà (Product::forgetPrice(), qui décharge aussi les prix préchargés) ; une écriture de prix qui passe à côté du service doit l'appeler elle-même, sans quoi le même produit continue de rendre l'ancien prix, jusque dans le pipeline de prix.
  • Dernière activité d'un panier : $cart->last_activity_at (Cart::lastActivityAt(), un CarbonImmutable à l'heure de la boutique) rend la plus récente de la date du panier et de celles de ses lignes (pivot.updated_at) — un panier rempli la veille ne passe pas pour oublié depuis sa création. Une requête qui l'a déjà calculée en SQL, pour trier ou filtrer, la sélectionne sous ce nom — en UTC, comme la base la rend : l'accesseur la convertit —, et c'est elle qui sert : faites-le sur une liste, sans quoi chaque panier charge ses lignes pour se dater.
  • Paniers vides élagués : Cart est MassPrunable, et le cœur planifie model:prune sur lui chaque nuit. Cart::prunable() vise les paniers sans produit ni commande, inchangés depuis Cart::EMPTY_CART_DAYS jours (sept) — vidés par leur client, ou nés avant qu'un panier n'attende son premier produit pour s'enregistrer. Un panier abandonné qui garde un produit reste : il se relance. L'élagage en masse supprime par requête, sans charger les modèles, donc sans aucun événement Eloquent (voir l'élagage des modèles de la doc Laravel) : ni vos observers de Cart, ni le nettoyage que HasContents fait à deleted. Ce qu'un package accroche à un panier ne disparaît donc pas avec lui par un événement ; une table à vous, reliée par une clé étrangère en cascade, suit en revanche.
  • Services injectés : Product et Cart résolvent leur service de prix par contrat au constructeur (ProductPriceService, CartPriceService), et MenuItem son MenuService (qui calcule getUrl(), null quand la cible de l'entrée n'existe plus ou n'a pas d'adresse) — rebinder le contrat change le comportement du modèle sans le toucher.
  • Menus lus d'un coup : un Menu est un arbre de contenus, dont chaque entrée (MenuItem) porte ses libellés, ses sous-entrées et sa cible — un rayon, un produit, une page. Lu entrée par entrée, un menu de quarante liens coûtait plus d'une centaine de requêtes à chaque page : un composant qui le rend le lit donc par Menu::named($name), qui charge l'arbre niveau par niveau (loadTree()) puis branche toutes les entrées sur un même MenuTargets (shareTargets()), chargé en une requête par type de cible. MenuItem::target() — que lit aussi la résolution d'URL du MenuService — rend alors la cible de mémoire ; une entrée lue seule, hors de son menu, relit la sienne à chaque appel, pour qu'une page republiée entre-temps s'y voie. Le chargement par niveau sert aussi vos propres arbres de contenus : Slab\Framework\Core\Content\ContentTree::load($models, $depth) fait une requête par classe et par niveau. Pas with('contents.contents…') : un niveau mêle des classes (entrées, libellés, liens, images), qu'Eloquent chargerait comme la seule classe de base, sans alias polymorphe.
  • Modèle de base : vos propres modèles étendent Slab\Framework\Models\AppModel — factories, casts JSON écrits en JSON_UNESCAPED_UNICODE, et dates lues à l'heure de la boutique (ReadsDatesInShopTimezone : shop.timezone, Europe/Paris par défaut). La base, elle, reste en UTC : une date lue puis réécrite y revient telle quelle, une date en texte sans fuseau (now()->toDateTimeString(), un import) s'entend en UTC, et une date sans heure (cast date) ne change pas de fuseau — la déplacer ferait reculer le jour d'un cran à chaque enregistrement. Une date qui ne passe pas par les casts du modèle — une valeur calculée en SQL, une colonne de pivot — se convertit à la main vers AppModel::shopTimezone(), comme le fait Cart::lastActivityAt().
  • Colonnes traduites : déclarez-les dans vos migrations par Slab\Framework\Core\Database\TranslatableColumns::add($table, 'name'), pas par $table->json(). Sur MariaDB, json est un LONGTEXT en collation binaire : une recherche like y distingue la casse et les accents — « patrix » ne trouve pas « Patrix » —, ce que SQLite, celui des tests, ne montre pas. add() pose la collation du texte (utf8mb4_unicode_ci) et garde la contrainte json_valid ; TranslatableColumns::convert($table, [...]) y fait passer une colonne existante. Une colonne JSON technique (réglages, instantanés) garde la sienne : ses comparaisons restent exactes.

Substituer un modèle du cœur ​

Choix de conception

Remplacer entièrement la classe d'un modèle du cœur (votre Product à la place du sien) n'est pas un point d'extension : ce serait spéculatif et coûteux (Laravel instancie les modèles par new, query, find, relations…), et les besoins réels sont déjà couverts — greffer des relations, contribuer des attributs, enrichir via traits/contentable, réagir via événements, et stabiliser les types via la morph map (le vrai point d'extension prospectif, voir ci-dessus). Un modèle dérivé ciblé resterait possible plus tard si un besoin concret l'exigeait, conçu avec son consommateur — pas par avance.

Voir aussi ​