Apparence
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 leCheckoutOptionRegistry, écrit — persisté aussitôt, totaux mémoïsés oubliés dans la foulée — parselectCheckoutOption(), effacé de même parforgetCheckoutOption()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 parselectedCheckoutOption()/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 adresseCô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 —, plusactivate()/deactivate()/isActive()et la colonne redéfinissable paractiveColumn(): c'est elle que lisent la colonne de listing et les actions groupées d'activation),SoftDeletes(celui du cœur ajoutereplicateSoftDelete()à 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. SurUser, c'estSoftDeletesqui 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 dateapproved_at, que litUser::isApproved(), et le scopeUser::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()etOrderNote::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 doncwithTrashed()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()etOrder::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 ; sanswithTrashed(), 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).Pagemontre 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 (slugetname, traduisibles) et ce qui se filtre et se trie (is_active,published_at, l'auteur).Addressva 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 accesseurnamecompose 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 :
ProductetCategoryimplémententStafe\LaravelContentable\Contracts\NamesStoredFilespar le traitSlab\Framework\Models\Traits\NamesFilesAfterName— une image envoyée s'appelle107-patrix-fauteuil-en-chene-massif-fr.jpgplutôt que107_fr.jpg. Le trait fournitstoredFileLabel($locale)(lenamedans la langue du fichier,nulls'il est vide, ce qui garde le nom simple) et, quand le nom change, renomme les fichiers déjà stockés (renameStoredFiles(), deHasContents) 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 porteHasContentset unnametraduisible. À 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 pasdeleting, qu'Eloquent annule en silence : écoutezModelDeleting(voir le back-office). C'est par les observers que passe aussi l'indexation :ProductsurchargebootSearchable()pour désactiver la synchro automatique de Scout au profit des observers — la surcharge ne garde que leSearchableScopeet diffère les macros de Scout àwhenBooted(), instancier un modèle pendant son propre boot levant uneLogicExceptiondepuis 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_idregroupe la famille,variant_rankdit 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 auSearchIndexRegistryne peut pas reliretoSearchableArray(): 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 —, quandtoSearchableArray(), 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éfixescopeXxx; 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 etpublished_at(nulle ou échue — une page se prépare et prend effet au jour dit), etPage::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 PHPSORT_LOCALE_STRINGrange bien les lettres accentuées, mais dans la seule langue queLanguageServiceProviderdonne 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émenteSlab\Framework\Models\Interfaces\RedirectableBySlug— un seul membre, l'accesseururl(): CastAttribute. Le middlewareSlugRedirects'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 rendrenull: une famille deProductn'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 deurldoit 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 deSlab\Framework\Models. - États ouverts :
products.statusreste une chaîne en base, maisProductStatusCastla traduit en objetProductStatuspris auProductStatusRegistry— le statut qu'ajoute un package est donc un statut comme les autres (key,label,visible,orderable,priority), sans migration. Les accesseursis_visible/is_orderablelisent les drapeaux de cet objet (visible,orderable),is_orderabley ajoutant$product->availability->orderable; les scopesvisible()/orderable(), eux, interrogent le registre (visibleKeys(),orderableKeys()) au lieu de comparer à « en ligne ».$product->availabilityvient du contratProductAvailabilityService(for($product)), substituable (WMS, ERP, multi-dépôt) : il rend unProductAvailability—state(chaîne libre,in_stock/low_stock/out_of_stockpar défaut, avec sonlabel()),orderable,available,message. Les colonnes que le cœur porte pour l'alimenter (stock_physical,stock_reserved,stock_threshold,allow_backorder, et le message traduisibleout_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_physicalmoinsstock_reserved,nullquand 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_newcomparecreated_atà la fenêtreshop.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 :
OrderStatusest 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 porteHasContributedAttributes, et un package y pose le drapeau que le cœur n'a pas à connaître —slab/invoicingy contribueissues_invoice, coché sur le statut auquel la boutique facture. Les quatre premiers drapeaux décrivent un avancement,is_cancelledun 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é paridàcreated_atégal — etOrder::visibleStatus(), le dernier qui ne soit pas masqué au client : c'est celui-là, jamaisstatus(), que lit le compte client ; une ligne dont le statut a depuis été supprimé y reste visible. Chaque ligne d'OrderStatusHistoryrecopie le nom appliqué — un instantané, qu'un statut renommé ne réécrit pas — et gardeorderStatus()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(), lesOrderNotela plus récente en tête : un journal où rien ne s'écrase, jamais montré au client, et dontauthor()se litwithTrashed()— 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 :OrderProductporte son nom, sa référence et ses montants figés — plus le drapeauis_gift, que le pivot du panier porte déjà à côté de laquantity—, etOrderProduct::product()ne sert qu'à rouvrir la fiche si le produit existe encore. La devise, elle aussi, se relitwithTrashed(): retirer une devise du front ne doit pas rendre illisibles les commandes passées avec elle. - Écritures qui ne se réécrivent pas :
OrderStatusHistoryetPriceposentconst 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é paridàcreated_atégal, commeOrder::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(), unCarbonImmutableà 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 :
CartestMassPrunable, et le cœur planifiemodel:prunesur lui chaque nuit.Cart::prunable()vise les paniers sans produit ni commande, inchangés depuisCart::EMPTY_CART_DAYSjours (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 deCart, ni le nettoyage queHasContentsfait à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 :
ProductetCartrésolvent leur service de prix par contrat au constructeur (ProductPriceService,CartPriceService), etMenuItemsonMenuService(qui calculegetUrl(),nullquand 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
Menuest 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 parMenu::named($name), qui charge l'arbre niveau par niveau (loadTree()) puis branche toutes les entrées sur un mêmeMenuTargets(shareTargets()), chargé en une requête par type de cible.MenuItem::target()— que lit aussi la résolution d'URL duMenuService— 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. Paswith('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 enJSON_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 (castdate) 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 versAppModel::shopTimezone(), comme le faitCart::lastActivityAt(). - Colonnes traduites : déclarez-les dans vos migrations par
Slab\Framework\Core\Database\TranslatableColumns::add($table, 'name'), pas par$table->json(). Sur MariaDB,jsonest unLONGTEXTen collation binaire : une recherchelikey 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 contraintejson_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
- Le conteneur & les contrats — pour remplacer un service.
- Les registres — le
ModelExtensionRegistryparmi les autres points de contribution. - Créer un package — où votre schéma vit (migrations possédées).