Apparence
Back-office
Le back-office (back.*, préfixe admin/) est du cœur. On l'étend par les registres et les hooks, on le personnalise par surcharge de vue.
Par où l'étendre
| Je veux… | Point d'extension | Mécanique |
|---|---|---|
| ajouter une entrée de menu | BackMenuRegistry::add() | registres |
| ajouter une tuile au tableau de bord | DashboardTileRegistry::add() | tableau de bord |
| déplacer une entrée de menu sans la redéfinir | BackMenuRegistry::prioritize() | le menu |
| ajouter des routes / un écran d'admin | RouteContributionRegistrar::back() | registres |
| ajouter une colonne / une action à une liste | DatatableExtensionRegistry | registres |
| ajouter une action sur les lignes cochées | BulkActionRegistry + BulkAction | actions groupées |
| rendre les lignes d'une liste déplaçables | AppDatatable::ORDER_HANDLE_CLASS / orderHandle() | ordonner |
| exporter / importer une liste en fichier | DataSchemaRegistry | exporter et importer |
| ajouter un onglet / une section à un écran | FormLayoutRegistry / ViewHookRegistry | hooks |
| valider un champ greffé sur un formulaire | FormRuleRegistry | registres |
| rendre un réglage administrable | FormLayoutRegistry + FormRuleRegistry | réglages |
| changer l'apparence d'un écran | surcharge vendor/… | surcharger une vue |
| réordonner le menu sans code | config/back_menu.php | le menu |
Menu de navigation
Le menu est un arbre contribué : le cœur ne connaît aucun menu figé, il déclare ses propres entrées au BackMenuRegistry comme le ferait n'importe quel package.
php
public function add(
string $key,
string $label,
?string $route = null,
?string $icon = null,
?string $parent = null,
int $priority = 0,
): void;$key— identifiant stable : c'est lui qu'on vise pour se rattacher (parent) ou réordonner.$routeabsent — l'entrée est un simple groupe, elle ne porte que ses filles (catalog,config…).$parent— clé de l'entrée parente ;nullplace l'entrée au premier niveau. L'arbre est récursif : un groupe peut lui-même être imbriqué (config.salesporteconfig.sales.taxes).$priority— ordre croissant, niveau par niveau. Le tri est stable : à priorité égale, l'ordre de déclaration tient.
Déplacer une entrée qui existe déjà ne demande pas de la redéclarer :
php
public function prioritize(string $key, int $priority): void;prioritize() ne change que le rang. Réenregistrer l'entrée d'un package pour la déplacer obligerait à recopier son libellé, sa route et son icône — donc à les figer, et à les voir cesser de suivre le package qui les possède. Une clé inconnue est ignorée sans erreur : un package désinstallé ne doit pas empêcher le menu de se construire.
La clé est unique, le parent doit exister
add() écrase l'entrée si la clé est déjà prise : le dernier appel gagne, libellé, route, icône, parent et priorité compris. C'est ainsi qu'un package reroute une entrée du cœur — c'est aussi comme ça qu'on l'efface par mégarde en réutilisant sa clé. Pour seulement changer son rang, prioritize() ne touche à rien d'autre.
À l'inverse, une entrée rattachée à un $parent jamais déclaré n'apparaît nulle part : tree() descend depuis les racines (parent === null) et ne visite que les clés connues.
Le cœur ouvre le menu sur une entrée racine qui n'est pas un groupe — dashboard (0), le tableau de bord (back.home) —, puis pose cinq groupes racines — orders (10), catalog (20), customers (30), appearance (40), config (50) — et espace les entrées filles de 10 en 10 pour laisser la place d'en insérer une. Les features optionnelles (transporteurs, promotions…) contribuent les leurs depuis leur package : voir l'exemple dans les registres.
tree() renvoie un list<BackMenuItem> — un objet en lecture seule (key, label, route, icon, children) dont les filles sont déjà résolues et ordonnées. C'est ce que consomme le composant de rendu Slab\Framework\View\Components\Back\Menu, qui n'y ajoute que l'URL résolue et l'état courant/actif.
L'entrée courante est celle dont l'adresse couvre le plus long de la page affichée, à une frontière de segment : la fiche d'un produit (admin/products/12/edition) allume « Produits », et non le tableau de bord (admin), qui préfixe toutes les pages ; admin/orders ne couvre pas admin/order_statuses. Ses groupes parents sont actifs, et le groupe replié qui la contient s'ouvre de lui-même.
Sous 992 px, le menu devient un tiroir qu'ouvre un bouton burger dans la barre du haut ([data-menu-toggle], aria-controls="back-menu") : il se referme au voile, à la touche Échap, ou quand l'écran s'élargit. Une entrée contribuée n'a rien à prévoir pour y figurer.
Réordonner sans toucher au code
config/back_menu.php réécrit la priorité de n'importe quelle entrée — y compris celles d'un package tiers — sans le forker :
php
'priorities' => [
'catalog.products' => 15,
],Le fichier a le dernier mot : sa valeur l'emporte sur celle passée à add() comme sur celle posée par prioritize().
Tableau de bord
Le tableau de bord (back.home) est la page d'accueil du back-office. Un package qui gère les annulations veut y afficher son chiffre à côté du chiffre d'affaires. Pourtant, le cœur ne doit pas connaître les annulations.
C'est pour cela que l'écran est assemblé. Le DashboardController ne calcule rien : il choisit la période et demande à chaque tuile du DashboardTileRegistry de se rendre pour cette période. Le cœur déclare ses propres tuiles (CoreDashboardTiles) comme n'importe quel package.
Le contrat
php
namespace Slab\Framework\Core\Dashboard\Contracts;
interface DashboardTile
{
public function key(): string;
public function width(): TileWidth;
public function render(DashboardPeriod $period): ?View;
}key()est un identifiant stable et unique dans le registre. On s'en sert pour remplacer, retirer ou déplacer la tuile.width()renvoie unTileWidth, qui donne le nombre de colonnes occupées sur 12 :Quarter(3),Third(4),Half(6),TwoThirds(8),Full(12). Les tuiles se placent les unes après les autres et passent à la ligne quand la place manque. Sur un écran étroit, elles prennent toute la largeur.render()renvoie la vue propre à la tuile. C'est un fragment et non une page : le contrôleur le place dans une cellule de la grille. Elle renvoienullquand elle n'a rien à montrer, et la tuile disparaît alors sans laisser de cellule vide.
Tout l'écran partage une seule période, DashboardPeriod. Les chiffres, le graphique et les classements portent donc toujours sur les mêmes jours. Elle expose quatre propriétés :
range: leDashboardRangechoisi par?period=. Les valeurs possibles sontlast_7_days,last_30_days(par défaut),month_to_dateetlast_12_months.currentetprevious: deuxPeriod. La période précédente a la même forme que la période courante (du 1er août au 18 août contre du 1er septembre au 18 septembre).interval: le pas des séries,Interval::Day, ouInterval::Monthsur douze mois.
Chaque période finit aujourd'hui compris, à l'heure de la boutique (ShopTime) : « ce mois-ci » commence à son minuit.
Une tuile peut ignorer la période. LatestOrdersTile affiche la commande de ce matin même quand on regarde les douze derniers mois. Elle charge ses commandes avec orderable.contents : le nom d'un client est un contenu (HasContents), et sans ce préchargement chaque ligne relirait le sien. Une tuile qui affiche des clients doit faire de même.
Une tuile en erreur ne bloque pas le back-office
Si render() ou le rendu de sa vue lève une exception, le contrôleur la signale (report()) et affiche l'écran sans la tuile. Le tableau de bord étant la page d'accueil du back-office, une tuile ajoutée par un package ne doit pas en bloquer l'accès. Si une tuile manque, commencez par consulter les logs.
Les tuiles du cœur
| Clé | Tuile (…\Core\Dashboard\Tiles) | Largeur | Priorité |
|---|---|---|---|
revenue, orders, average_order_value, new_customers | MeasureTile | Quarter | 100, 110, 120, 130 |
revenue_series | SeriesTile | Full | 200 |
best_sellers, top_customers | RankingTile | Third | 300, 310 |
latest_orders | LatestOrdersTile | Third | 320 |
Les trois premières classes sont génériques : chacune reçoit la clé d'un indicateur et va le chercher au moment de l'affichage. Si l'indicateur a été remplacé, c'est sa nouvelle version qui s'affiche. S'il a été retiré, la tuile disparaît au lieu de provoquer une erreur. Leur clé est celle de l'indicateur, suffixée par _series pour SeriesTile.
MeasureTile(IndicatorRegistry $indicators, string $indicator, bool $higherIsBetter = true, TileWidth $width = TileWidth::Quarter)affiche la valeur d'unMeasureet son évolution par rapport à la période précédente.SeriesTile(IndicatorRegistry $indicators, string $indicator, ?string $emptyMessage = null, TileWidth $width = TileWidth::Full)affiche unMeasureintervalle par intervalle, chaque colonne à côté de celle de même rang dans la période précédente.RankingTile(IndicatorRegistry $indicators, string $indicator, ?string $ordering = null, int $limit = 5, ?string $emptyMessage = null, TileWidth $width = TileWidth::Third)affiche les premiers d'unRanking. Sans$ordering, elle utilise le premier mode de classement de l'indicateur.
$emptyMessage est une clé de traduction, traduite au moment de l'affichage.
LatestOrdersTile(CurrencyService $currencies, int $limit = 5, TileWidth $width = TileWidth::Third) ne lit pas d'indicateur : elle affiche les $limit dernières commandes, quel que soit leur statut. Leur total y est hors taxes, dans la devise de la commande, formaté par CurrencyService::format() sans être reconverti : un total de commande est figé.
Afficher un indicateur
L'indicateur CancelledOrders, déclaré dans les registres, n'a pas besoin de code d'affichage : il suffit de l'afficher dans une MeasureTile, dans le boot() du provider du package.
php
use Acme\Cancellations\Indicators\CancelledOrders;
use Slab\Framework\Core\Dashboard\Contracts\DashboardTileRegistry;
use Slab\Framework\Core\Dashboard\Tiles\MeasureTile;
use Slab\Framework\Core\Indicator\Contracts\IndicatorRegistry;
public function boot(): void
{
$indicators = $this->app->make(IndicatorRegistry::class);
$indicators->add(CancelledOrders::class);
$this->app->make(DashboardTileRegistry::class)->add(
new MeasureTile($indicators, 'cancelled_orders', higherIsBetter: false),
140,
);
}higherIsBetter: false: une hausse des annulations est une mauvaise nouvelle. L'évolution s'affiche alors avec le tonbad(Change::tone()). Le sens de la variation ne suffit pas à dire si elle est bonne : c'est la tuile, qui connaît son chiffre, qui l'indique. Sous un demi-point d'écart, le chiffre est dit stable (flat) et le ton resteneutral.- Les priorités du cœur laissent de la place entre elles. Avec
140, la tuile se place après les chiffres clés. Ces quatre quarts remplissent déjà leur rangée, donc elle commence la rangée suivante. À priorité égale, les tuiles restent dans leur ordre de déclaration. - Pour ajouter une nouvelle clé, pas besoin d'attendre que le cœur ait démarré.
Écrire sa propre tuile
On implémente le contrat quand aucune tuile générique ne convient. Par exemple, pour comparer le chiffre d'affaires à la même période de l'année précédente plutôt qu'à la période précédente :
php
namespace Acme\Reports\Dashboard;
use Illuminate\Contracts\View\View;
use Slab\Framework\Core\Dashboard\Change;
use Slab\Framework\Core\Dashboard\Contracts\DashboardTile;
use Slab\Framework\Core\Dashboard\DashboardPeriod;
use Slab\Framework\Core\Dashboard\Figures;
use Slab\Framework\Core\Dashboard\TileWidth;
use Slab\Framework\Core\Indicator\Contracts\IndicatorRegistry;
use Slab\Framework\Core\Indicator\Contracts\Measure;
use Slab\Framework\Core\Indicator\MeasureUnit;
final class RevenueYearOverYearTile implements DashboardTile
{
public function __construct(private readonly IndicatorRegistry $indicators) {}
public function key(): string
{
return 'revenue_year_over_year';
}
public function width(): TileWidth
{
return TileWidth::Quarter;
}
public function render(DashboardPeriod $period): ?View
{
$revenue = $this->indicators->find('revenue');
if (! $revenue instanceof Measure) {
return null;
}
$current = $revenue->measure($period->current);
$lastYear = $revenue->measure($period->current->yearBefore());
return view('back.dashboard.tiles.measure', [
'label' => __('Chiffre d\'affaires, contre l\'an dernier'),
'value' => Figures::value($current),
'excludingTax' => $revenue->unit() === MeasureUnit::Money,
'previous' => Figures::value($lastYear),
'change' => Change::between(Figures::magnitude($current), Figures::magnitude($lastYear)),
]);
}
}php
$this->app->make(DashboardTileRegistry::class)->add(RevenueYearOverYearTile::class, 150);- Lisez un indicateur au lieu de recompter. Le tableau de bord et l'IA affichent alors les mêmes chiffres. Si l'indicateur a été retiré,
find()renvoienull,render()aussi, et la tuile disparaît. Figuresformate les chiffres comme le reste de l'écran : dans la langue de l'écran, et sans centimes pour un chiffre clé.Change::between()calcule l'évolution — son troisième argument,$higherIsBetter, vauttruepar défaut —, ou renvoienullquand elle ne peut pas se calculer, par exemple quand la période de comparaison vaut zéro.back.dashboard.tiles.measureest la vue des chiffres clés du cœur. Elle attendlabel,value,excludingTax,previousetchange. Si vous préférez votre propre vue (voir les vues de package de Laravel), écrivez un fragment sans layout.add()accepte une instance ou une classe. Une classe est résolue par le conteneur dès l'appel àadd(), et sakey()est lue immédiatement. Gardez donc un constructeur léger et faites le calcul dansrender().
Remplacer, retirer ou déplacer une tuile du cœur
Le cœur déclare ses tuiles dans le boot() de son provider. Si votre provider démarre avant, le cœur écrase votre remplaçante, et forget() ou prioritize() visent une clé qui n'existe pas encore. Attendez donc que l'application ait démarré :
php
use Slab\Framework\Core\Dashboard\Contracts\DashboardTileRegistry;
use Slab\Framework\Core\Dashboard\Tiles\RankingTile;
use Slab\Framework\Core\Indicator\Contracts\IndicatorRegistry;
public function boot(): void
{
$this->app->booted(function (): void {
$tiles = $this->app->make(DashboardTileRegistry::class);
// Les meilleures ventes classées par unités vendues, à la même place.
$tiles->add(new RankingTile(
$this->app->make(IndicatorRegistry::class),
'best_sellers',
ordering: 'quantity',
emptyMessage: 'Aucune vente sur cette période.',
), 300);
$tiles->prioritize('latest_orders', 290);
$tiles->forget('top_customers');
});
}Remplacer une tuile réinitialise son rang
Si vous appelez add() avec une clé déjà prise, la nouvelle tuile remplace l'ancienne et repart de zéro. Elle prend la priorité passée en argument (0 par défaut, donc en tête de l'écran) et se place en dernier parmi les tuiles de même priorité. Repassez la priorité d'origine indiquée dans le tableau ci-dessus. Pour déplacer une tuile sans rien changer d'autre, utilisez prioritize(). Une clé inconnue y est ignorée : un package désinstallé n'empêche pas l'écran de s'afficher.
Les tuiles du cœur sont final. Pour en changer le comportement, on implémente le contrat plutôt que d'étendre la classe. Pour en changer seulement l'apparence, surchargez la vue back.dashboard.tiles.* concernée. Le registre figure parmi les points d'extension.
Listes (datatables)
Les listings du back-office sont des classes *Datatable (sur stafe/laravel-datatable) : colonnes, recherche, édition en ligne, export. Pour ajouter une colonne ou une action à une datatable existante (sans la forker), utilisez le DatatableExtensionRegistry — voir les registres :
php
$this->app->make(DatatableExtensionRegistry::class)->extend('products', function (Datatable $datatable) {
$datatable->columns()->column('supplier')->label(__('Fournisseur'));
});La greffe tient à une seule ligne côté cœur : chaque builder se termine par return static::extended($datatable);. AppDatatable::extended() passe la datatable au registre, qui applique les contributions enregistrées pour son id (Datatable::getId()), dans l'ordre d'enregistrement. extended() ouvre ensuite la sélection si le listing porte des actions groupées, pose enfin les colonnes updated_at / created_at, puis remonte la poignée d'ordre en tête de ligne. Une datatable construite sans ->id(...) ne reçoit rien.
Les deux colonnes de dates ferment donc la marche après les contributions, et le builder ne les déclare pas : déclarées par lui, elles se retrouvaient au milieu du tableau dès qu'un package ajoutait une colonne. Le listing des attributs les affichait par exemple en deuxième et troisième position, devant le type et le rôle. Un builder qui en habille une malgré tout — il lui donne un libellé — la garde telle qu'il l'a voulue — son nom, son rendu, sa place — et n'en reçoit que la garde commune : une date ne se retouche pas en ligne.
Déclarée n'est pas habillée : une contribution déclare parfois la colonne nue, seulement pour l'atteindre (lui retirer son tri). Son libellé est alors encore son nom de champ, et elle reçoit l'habillage commun — libellé, recherche par date, affichage datetime — comme si personne n'y avait touché. Sans cela, le listing affichait « UPDATED_AT » en en-tête et des horodatages bruts dans les cellules. Enfin, « Mise à jour » n'est posée que si le modèle horodate ses modifications (getUpdatedAtColumn()) : un journal ne se réécrit pas, sa table n'a donc pas la colonne, et l'afficher offrirait un tri et une recherche qui tombent.
| id | Classe | Portée |
|---|---|---|
areas | AreasDatatable::get() | zones |
attributes | AttributesDatatable::getRoots() / getChildren(Attribute $attribute) | attributs racines / filles |
attribute_values | AttributeValuesDatatable::get(Attribute $attribute) | valeurs d'un attribut |
carts | CartsDatatable::get() | paniers non vides |
currencies | CurrenciesDatatable::get() | devises |
languages | LanguagesDatatable::get() | langues |
order_statuses | OrderStatusesDatatable::get() | statuts de commande |
orders | OrdersDatatable::get() | commandes |
pages | PagesDatatable::get() | pages de contenu (titre et URL traduits) |
products | ProductsDatatable::get() / getVariants(Product $product) | produits / variantes |
taxes, tax_rules | TaxesDatatable, TaxRulesDatatable | taxes et règles |
team | TeamDatatable::get() | utilisateurs withRole('team') |
users | CustomersDatatable::get() | clients, comptes supprimés compris |
L'id n'est pas le nom de la ressource
La liste des clients porte l'id users, pas customers. Et products est partagé par la liste principale et par celle des variantes : une contribution à products touche les deux, alors que les deux builders diffèrent. getVariants() ajoute une colonne « Attributs », ne pose pas de bouton d'export et n'a pas de colonne d'ordre. Enfin, AttributesDatatable::getRoots()/getChildren() dérivent de default(), déjà passée par extended() : écrivez donc des contributions idempotentes (une colonne posée deux fois reste une colonne).
Le cœur utilise deux formes d'actions de ligne :
actions()->default('back.taxes.edit', 'back.taxes.destroy')pour les actions standard d'édition et de suppression ;actions()->action('back.areas.dt_actions.delete')pour une action nommée, rendue par sa propre vue.
Le second argument de default() est la route de suppression. Les listings du cœur qui suppriment par cette action y passent la route destroy de leur ressource. Si on l'omet, le paquet utilise sa route générique datatables.ajax.delete-row. Avec false, l'action de suppression disparaît.
AreasDatatable, CurrenciesDatatable, LanguagesDatatable et PagesDatatable combinent les deux formes : ->default('back.areas.edit', false), puis leur vue de suppression. AttributeValuesDatatable n'a que des actions nommées (dt_actions.edit puis dt_actions.delete). Un listing qu'on lit sans le modifier n'a qu'une action nommée, celle de consultation que pose setShowAction() : c'est le cas des paniers, qui appartiennent à leur client.
Le listing des commandes
Le listing orders n'écrit aucune clause lui-même. Chacun de ses filtres appelle OrderSearch::apply() avec un OrderCriteria. Ce service sert aussi la page « mes commandes » d'un client et l'outil list_orders d'un agent. Une commande trouvée ici l'est donc aussi ailleurs. Et « Statut » désigne partout le statut courant : ce n'est pas une colonne, mais le dernier historique.
php
public static function filterCustomer(Column $column, Builder $query, mixed $value): void;
public static function filterStatus(Column $column, Builder $query, mixed $value): void;
public static function filterTotal(Column $column, Builder $query, mixed $value): void;Le builder charge d'avance status.orderStatus, orderable et currency, sinon la pastille de chaque ligne les lirait commande par commande. Il laisse « Client » et « Statut » non triables (->sort(false)) : on trie une commande par sa date ou son montant, on cherche son client.
displayCustomer() rend une cellule vide quand la commande n'a plus de client (un invité effacé, une reprise de données) : la ligne reste et le tableau ne plante pas. Le filtre natif de la colonne id porte sur orders.id, et rien d'autre. La recherche libre (numéro, client ou produit) est celle du service, et c'est la page client qui l'expose.
On y retrouve trois conventions réutilisables :
- les options d'un
selectpeuvent venir d'une méthode nommée :->options([self::class, 'statusOptions']), de signaturestatusOptions(Column $column): array. Le référentiel est lu au rendu, y compris les statuts que plus aucune commande ne porte : un filtre limité au présent empêcherait de chercher dans le passé ; - un intervalle tient dans une seule case :
filterTotal()lit100-500,100-,-500ou un montant seul (virgule ou point décimal), car la datatable n'offre qu'un champ par colonne. Une saisie qui n'est pas un montant ne filtre rien, plutôt que de vider le tableau sans explication ; - l'action par défaut peut viser un écran de consultation :
->default('back.orders.show', delete_route: false).
Un total de commande ne se reconvertit pas
displayTotal() formate currency_total_price_ti dans la devise de la commande (CurrencyService::format()), alors que CartsDatatable::displayTotalTi() appelle price(). Un panier est encore en cours, une commande est figée. Lui appliquer le taux du jour afficherait un total que personne n'a jamais payé.
Le seuil du filtre de montant, lui, s'entend dans la devise de référence. Le seuil et le montant affiché ne coïncident donc qu'en boutique mono-devise. Voir Prix & taxes et Commande & checkout.
Le listing des clients
Le listing users interroge la table corbeille comprise (withTrashed()). Un compte inactif est un compte supprimé : sans la corbeille, il disparaîtrait de la liste et plus personne ne pourrait le réactiver. La colonne « Statut » lit donc deleted_at, l'affiche en pastille via AccountStatus::of($user) et propose AccountStatus::getList() à son filtre (voir Comptes & autorisations).
php
public static function getStatus(mixed $deleted_at, User $user): string;
public static function filterStatus(Column $column, Builder $query, mixed $status): Builder;
public static function getApproval(mixed $approved_at, User $user): string;
public static function filterApproval(Column $column, Builder $query, mixed $approval): Builder;filterStatus() ne traite qu'un cas particulier : « inactif » cherche whereNotNull('deleted_at'), toute autre valeur whereNull(). Une valeur inattendue montre donc les comptes actifs plutôt qu'une liste vide. La colonne n'est pas éditable en ligne (->edit(false)) : on ne supprime pas un compte en modifiant une cellule. « Dernière connexion » (last_login_at) suit la même règle : affichée en datetime, cherchée par date, jamais modifiable. C'est une trace, pas un réglage.
La colonne « Inscription » (approved_at) indique si le compte attend encore la validation d'un administrateur. Elle affiche la date de validation, ou une pastille « En attente » quand isApproved() est faux. Son filtre (« Validée » / « En attente ») regroupe les demandes en attente : pending cherche whereNull('approved_at'), toute autre valeur whereNotNull().
Elle n'est pas éditable en ligne non plus : une inscription se valide depuis la fiche client (back.customers.approve). Le mode d'inscription est un réglage administrable (shop.registration.mode, voir réglages de boutique et Comptes & autorisations).
Les rôles, eux, suivent le référentiel au lieu d'être figés : voir Autorisations.
Le listing des paniers
Le listing carts ne montre que les paniers non vides (whereHas('products')), et il se consulte sans se modifier : un panier appartient à son client. Sa seule action de ligne est donc setShowAction($datatable, 'back.carts.show'), qui ouvre la fiche du panier. Le builder charge d'avance cartable, order et products, sinon le total de chaque ligne les lirait panier par panier.
php
public static function displayCartable(mixed $value, Cart $cart): string;
public static function displayOrder(mixed $value, Cart $cart): string;
public static function orderOptions(Column $column): array;
public static function filterOrder(Column $column, Builder $query, mixed $value): void;- « Client » affiche le nom et l'e-mail du titulaire, ou « Visiteur non identifié » quand le panier n'en a pas : une case vide passerait pour un oubli. Déclarée
->html(), la colonne n'échappe plus rien :displayCartable()passe donc elle-même le nom et l'e-mail pare(). - « Commande » sépare un panier abandonné d'un panier qui a abouti : elle pointe vers la commande née du panier (
back.orders.show). Son filtre proposeopen(« Non commandé ») etordered(« Commandé ») ; cocher les deux, ou aucun, n'écarte rien. La colonne n'est pas triable. - « Mise à jour » est renommée « Dernière activité » après
extended(): le service date le panier à chaque produit ajouté ou retiré, et à chaque changement de quantité.
Enregistrer depuis un écran
Créer ou modifier un objet passe par le pendant du service de suppression, Slab\Framework\Core\Saving\Contracts\SavingService — le chemin des écrans, et celui d'une IA qui en fait autant. Il reçoit les valeurs validées par la Form Request, règles des paquets comprises (AppFormRequest les fusionne), et jamais la requête entière : un champ que rien ne valide n'a pas à s'écrire.
php
interface SavingService
{
/** @throws ActionCancelled si un écouteur de ModelSaving s'y oppose. */
public function save(Model $model, array $values): Model;
}DefaultSavingService pose les valeurs (fill()), émet ModelSaving::propose($model) — les nouvelles valeurs déjà posées : isDirty() dit ce qui change, exists s'il s'agit d'une création —, lève ActionCancelled si un écouteur a appelé cancel(), et sinon enregistre. Un contrôleur qui étend BackController passe par saveModel() :
php
protected function save(SaveTaxRequest $request, ?Tax $tax = null): Tax
{
return $this->saveModel($tax ?? new Tax, $request->validated());
}Refusé, l'écran revient tel que la personne l'a rempli (withInput()), le motif en error ; une requête JSON reçoit ce motif en 422, comme une suppression refusée. Un objet dont l'enregistrement demande plus que ses colonnes — les rôles d'un compte, les taxes d'une règle — garde son service à lui.
Supprimer depuis un listing
Certains objets ne se suppriment pas : la langue de la boutique, son propre compte, un transporteur qu'un paquet a installé. Cacher le bouton ne suffit pas, car la route resterait appelable. Et l'événement deleting d'Eloquent annule sans rien dire : delete() rend false, et l'écran n'a aucun motif à afficher. Chaque suppression du back-office passe donc par un contrat du domaine, Slab\Framework\Core\Deletion\Contracts\DeletionService.
php
interface DeletionService
{
/** @throws ActionCancelled si un écouteur de ModelDeleting s'y oppose. */
public function delete(Model $model): void;
}DefaultDeletionService émet ModelDeleting::propose($model), lève ActionCancelled si un écouteur a appelé cancel(), et sinon appelle $model->delete(). Le modèle décide de ce que « supprimer » veut dire : la corbeille pour les comptes et les devises (SoftDeletes), l'effacement pour le reste.
Un contrôleur qui étend BackController n'appelle pas le service lui-même : il passe par deleteModel(Model $model, string $deleted, string $back), comme tous ceux du cœur et des paquets.
php
public function destroy(Carrier $carrier): JsonResponse|RedirectResponse
{
return $this->deleteModel($carrier, __('Transporteur supprimé.'), route('back.carriers.index'));
}| Demande | Suppression faite | Suppression refusée |
|---|---|---|
| JSON (listing) | 200, {"message": $deleted} | 422, {"message": <motif>} |
| formulaire sans script | redirection vers $back, success en session | redirection vers $back, error en session |
Un refus répond 422, pas 403 : le groupe back a déjà vérifié le droit d'agir, c'est cet objet qui ne se supprime pas. Le listing passe la route destroy de sa ressource à son action : actions()->default('back.taxes.edit', 'back.taxes.destroy'). Une vue d'action personnalisée la cite de même (route('back.languages.destroy', $model)). Le module back/datatable-deletion.js affiche ensuite le message rendu, succès ou refus.
Refuser une suppression
On ne remplace pas le service : on écoute ModelDeleting, qui porte le $model visé. Le cœur s'en sert lui-même — RefuseDeletingShopDefaults pour la langue, la devise et la zone de la boutique, RefuseDeletingOneself pour son propre compte —, et le paquet slab/carrier aussi :
php
class RefuseDeletingPackageCarriers
{
public function handle(ModelDeleting $deleting): void
{
if ($deleting->model instanceof Carrier && $deleting->model->isProvidedByPackage()) {
$deleting->cancel(__('Un transporteur posé par un paquet se désactive : il ne se supprime pas.'));
}
}
}
// dans le provider du paquet
Event::listen(ModelDeleting::class, RefuseDeletingPackageCarriers::class);Si plusieurs écouteurs refusent, c'est le motif du premier qui s'affiche ; sans motif, le message est « Action refusée. ». Voir les événements annulables.
Changer la manière de supprimer
Remplacer le contrat change la suppression pour tout ce qui passe par lui : archivage, audit tenu ailleurs. DefaultDeletionService est final : on implémente le contrat et on le lie ($this->app->bind(DeletionService::class, …)), ou l'on décore le service avec extend pour ajouter un effet sans rien retirer.
Un remplaçant doit continuer à proposer ModelDeleting
Sans lui, tous les refus disparaissent, ceux du cœur comme ceux des paquets. Levez ActionCancelled en cas de refus : c'est ce qu'attend deleteModel() pour répondre 422.
Pas de transaction
DefaultDeletionService n'en ouvre pas : certains effets d'une suppression partent en file, comme la réindexation d'un produit, et un job traité avant le commit relirait le produit encore présent.
La route générique des listes ne passe pas par le domaine
Une action de suppression sans route vise datatables.ajax.delete-row, la route du paquet de listes : elle appelle $model->delete() sans consulter ModelDeleting. Depuis stafe/laravel-datatable 2.0, elle ne supprime plus que les lignes d'une liste qui déclare la suppression (deletable()), et le cœur réserve le groupe datatable au back-office. Un écran slab passe toujours sa route destroy.
Actions groupées
Une action qui s'applique aux lignes cochées se déclare au BulkActionRegistry (Slab\Framework\Core\View\Contracts\BulkActionRegistry), sous l'id du listing. C'est la même clé que pour les contributions de colonnes.
php
public function add(string $datatable, BulkAction $action): void;
public function for(string $datatable): array; // triées par priorité croissante
public function allowedFor(string $datatable): array; // celles que l'utilisateur courant peut exécuter
public function get(string $datatable, string $key): ?BulkAction;add() remplace une clé déjà enregistrée pour ce listing : c'est ainsi qu'un projet redéfinit une action du cœur au lieu d'avoir à la retirer.
L'action n'est pas écrite dans l'écran : elle est déclarée dans un BulkAction (Slab\Framework\Core\View), qui regroupe libellé, confirmation, droit exigé et exécution :
php
use Illuminate\Database\Eloquent\Collection;
use Slab\Framework\Core\View\BulkAction;
use Slab\Framework\Core\View\BulkActionReport;
$this->app->make(BulkActionRegistry::class)->add('products', BulkAction::make('archive', Product::class)
->label(fn (): string => __('Archiver'))
->confirm(fn (): string => __('Archiver les lignes cochées ?'))
->authorize(fn (): bool => auth()->user()->can('archive_products'))
->priority(30)
->handle(fn (Collection $products): BulkActionReport => $this->archive($products)));make(string $key, string $model): la clé stable est celle que le formulaire transmet, et le modèle indique d'où viennent les lignes cochées ;label()etconfirm()acceptent une fermeture, car les actions se déclarent au boot des providers, avant que la locale de la requête ne soit fixée. La confirmation n'inclut pas le nombre de lignes : il n'est connu qu'au clic, et une phrase traduite ne se recompose pas dans le navigateur ;authorize(): sans le droit, l'action n'est ni proposée ni exécutable ;handle()reçoit les modèles déjà résolus (Collection<int, Model>), jamais des identifiants bruts. Les lignes qui n'existent plus sont écartées avant l'appel ;- le retour est un
BulkActionReport(affected: …, errors: […]), qui exposeskipped()ethasErrors(). Sans retour, le cœur considère toutes les lignes reçues comme traitées. Sur cinquante lignes, une action réussit rarement partout : les motifs des lignes écartées doivent remonter à l'administrateur, sinon il croit que l'action a porté sur toutes.
Une seule route les exécute toutes : POST admin/datatables/{datatable}/bulk (back.datatables.bulk, BulkActionsController). Elle :
- résout l'action par son listing et sa clé, et répond 404 si le listing ne la déclare pas (vue de l'extérieur, une action non déclarée n'existe pas) ;
- revérifie le droit et répond 403 sinon, car masquer une action à l'écran ne protège pas d'une requête directe ;
- charge les lignes par leur clé primaire ;
- exécute l'action et renvoie le compte rendu.
Un référentiel activable n'a rien à écrire
BulkActivation::register($registry, 'suppliers', Supplier::class) pose les deux actions « Activer » / « Désactiver » d'un modèle qui utilise Activable, avec leur confirmation et leur rapport. Une ligne déjà dans l'état demandé ne compte pas comme une erreur : elle n'est simplement pas comptée. Dans le cœur, seuls currencies et languages l'utilisent.
La barre elle-même ne connaît ni action ni colonne. <x-back.bulk-actions datatable="…"/> demande au registre ce que l'utilisateur courant a le droit d'exécuter (allowedFor()), et au DataSchemaRegistry si le listing s'échange en fichier (exporter et importer). Un listing qui ne déclare ni action ni schéma n'affiche pas de barre vide : shouldRender() répond false. Chaque vue d'index du cœur la pose, avec l'id du listing — datatable="users" sur l'écran des clients, comme pour les contributions de colonnes. Un écran livré par un package la pose de même : sans elle, ses actions groupées ne s'afficheraient nulle part.
Elle tient en deux blocs, parce que deux natures s'y côtoient. À gauche, ce qui porte sur la sélection : un encart qui n'existe qu'à partir d'une ligne cochée, le compte des lignes, une action par bouton — dérouler, choisir, appliquer demandait trois gestes pour ce qui en vaut un — et « Tout décocher ». À droite, ce qui porte sur la liste entière : l'export et l'import de fichier. Les lignes cochées ne sont connues que du navigateur : back/bulk-actions.js les demande à la datatable (getSelectedItems()) et les dépose en champs cachés au moment de l'envoi. Sans script, l'encart reste caché, plutôt qu'un geste qui partirait sur une sélection vide.
Le cœur ajoute seul la colonne de cases à cocher : extended() appelle AppDatatable::setSelectable() après les contributions externes, et pose l'option Select dès que for($datatable->getId()) n'est pas vide. Un package qui déclare la première action groupée d'un listing du cœur active donc la sélection sans toucher au builder. Sans ->id(...), getId() vaut null : ni contributions, ni sélection.
La sélection est souvent déjà là
Dans stafe/laravel-datatable, Export étend Select et reprend ses options : un listing qui porte le bouton d'export du tableau a déjà ses cases à cocher, dont se sert « exporter les lignes sélectionnées ». setSelectable() couvre les deux cas restants. Le premier : un listing qui a des actions sans appeler setExportable() — dans le cœur, un seul builder est dans ce cas, ProductsDatatable::getVariants() ; les listings d'attributs, eux, héritent du bouton d'export de AttributesDatatable::default(). Le second : un listing échangeable, à qui setExportable() ne pose plus rien (exporter et importer) — currencies et languages tiennent donc leurs cases à cocher de leurs seules actions groupées.
Ordonner les produits d'une catégorie
Le rang d'affichage d'un produit appartient au lien, pas au produit. Il est stocké sur le pivot category_product.order, et un même produit peut avoir un rang différent dans chaque catégorie.
Deux écrans le manipulent, et tous deux écrivent au même endroit :
| Où | Le geste |
|---|---|
| onglet Produits de la fiche catégorie | on glisse les lignes ; l'ordre soumis est celui du DOM |
| listing des produits | on filtre par catégorie, puis on glisse les lignes par leur poignée |
Sur le listing, la catégorie se choisit au-dessus du tableau. Ce n'est pas un filtre de colonne comme les autres : c'est lui qui rend l'ordre modifiable. La poignée n'apparaît que lorsqu'une seule catégorie est choisie, puisque deux catégories ont deux ordres différents. Ce choix trie aussi la liste selon l'ordre de cette catégorie, celui qu'on s'apprête à modifier.
Côté datatable, tout tient dans une colonne. ProductsDatatable::setOrderColumn() déclare category_order, qui porte :
- la poignée (
displayOrderHandle()), rendue seulement quandfilteredCategoryId()trouve une catégorie ; - le filtre de catégorie (
filterByCategory()) ; - la classe
AppDatatable::ORDER_HANDLE_CLASS.
La catégorie choisie n'est jamais stockée dans l'objet sérialisé : filteredCategoryId() la relit dans la requête AJAX (request()->get('columns')), où le sélecteur l'a placée. Quand une seule catégorie est cochée, filterByCategory() trie sur le rang du pivot, via une sous-requête CategoryProduct posée avant le tri de la datatable, qui prend donc le dessus.
Le nouvel ordre permute les rangs des lignes déplacées au lieu de tout renuméroter : le listing est paginé, et repartir de 1 écraserait l'ordre des pages qu'on n'a pas sous les yeux.
La même convention permet de rendre votre propre référentiel ordonnable. Une colonne qui porte ORDER_HANDLE_CLASS est remontée en tête de ligne par extended() (pinOrderColumn()) : un builder peut la déclarer en premier, mais pas une contribution, car la collection du paquet n'ajoute qu'en fin de liste. Elle reste aussi épinglée au défilement horizontal, à côté des cases à cocher (resources/js/back/pinned-columns.js). Le balisage vient de AppDatatable::orderHandle() : le sélecteur [data-order-handle] fait le lien entre le tableau et le script qui rend ses lignes déplaçables.
Deux pièges du pivot category_product
La relation Category::products() trie sur l'alias pivot_order, que seule une sélection complète produit : un pluck() de deux colonnes sur cette relation échoue sur MySQL. Interrogez plutôt le pivot directement.
Par ailleurs, l'ordre du pivot est géré par un paquet dont les observateurs décalent les voisins à chaque écriture. Une permutation déjà cohérente doit donc s'écrire dans un withoutEvents().
Filtrer une colonne depuis l'extérieur du tableau
Le sélecteur définit la valeur de recherche de la colonne (column(i).search(v).draw()), et le serveur en déduit la catégorie. La colonne déclare donc un filtre sans widget : Search::make('text')->view('datatable::search.blank'). Le type « custom » du paquet semblerait plus naturel, mais sa fabrique assigne null à une propriété typée string : il est inutilisable.
Le champ passe par <x-form.fields.select>, comme tous les selects du back-office, car c'est ce composant qui embarque tom-select. Attention : tom-select n'émet aucun événement DOM. Il faut s'abonner à son propre événement (select.tomselect.on('change', …)), et non à un change natif qui ne se déclenche jamais.
Exporter et importer
Un listing s'échange en fichier dès qu'il déclare un jeu de données au DataSchemaRegistry, sous son id. Sans schéma, ni export ni import. Le socle ne déduit pas les colonnes de la table : une colonne technique ajoutée plus tard se retrouverait sinon dans un fichier client sans que personne ne l'ait voulu. Le cœur ne déclare que deux schémas, currencies et languages (CoreDataSchemas). C'est aussi le schéma qui fait apparaître les boutons d'échange dans la barre du listing.
Un seul export par listing
Deux exports se retrouvaient côte à côte sous la même icône : celui du tableau (setExportable()), qui tire un Excel de ce qui est affiché, et celui-ci, qui sort les colonnes déclarées et se réimporte. Rien ne les distinguait, et l'un des deux ne rend pas un fichier qu'on puisse renvoyer. Là où le schéma existe, c'est donc l'échange qui répond : setExportable() s'abstient dès que DataSchemaRegistry::has() reconnaît l'id du listing.
php
use Slab\Framework\Core\Data\Contracts\DataSchemaRegistry;
use Slab\Framework\Core\Data\DataColumn;
use Slab\Framework\Core\Data\DataSchema;
$this->app->make(DataSchemaRegistry::class)->register('brands', DataSchema::make(Brand::class)
// Sans écriture : la colonne sort au fichier, l'import l'ignore.
->column(DataColumn::make('id')->label(fn (): string => __('Id')))
->column(DataColumn::make('name')
->label(fn (): string => __('Nom'))
->rules(['required', 'string', 'max:255'])
->write(fn (Brand $brand, mixed $value) => $brand->name = (string) $value)));Un seul schéma décrit les deux sens, volontairement : un fichier exporté doit pouvoir être réimporté après modification, et deux descriptions séparées finiraient par diverger.
Ce que le socle garantit à l'import :
| Situation | Ce qui se passe |
|---|---|
| identifiant vide | la ligne est créée |
| identifiant connu | la ligne est mise à jour… |
| …et rien n'a changé | elle est comptée inchangée, pas « mise à jour » |
| identifiant introuvable | la ligne est écartée (la créer transformerait une faute de frappe en doublon) |
| une règle échoue | la ligne est écartée, les autres passent, et son numéro de ligne est indiqué |
| ligne plus courte que l'en-tête | les colonnes manquantes sont considérées comme vides |
Les en-têtes du fichier sont reconnus par leur libellé comme par leur clé : un fichier produit par un script n'a pas à connaître les libellés de l'interface.
Pourquoi la comparaison est lâche
Un fichier relu ne contient que des chaînes. Pour isDirty(), '1.0000' ou '0' diffèrent toujours de la valeur stockée (un float ou un bool). Sans comparaison lâche, réimporter un export intact annoncerait tout le catalogue comme mis à jour. Le compte rendu ne signale « mise à jour » que ce qui a changé.
L'export porte sur les lignes cochées s'il y en a, sur tout le listing sinon — son bouton le dit (« Exporter la liste », « Exporter la sélection (2) ») au lieu de le laisser deviner. Il reçoit les identifiants de la même façon qu'une action groupée, et une sélection vide vaut « tout le listing ».
Le CSV est le format par défaut, car un tableur l'ouvre sans rien installer. Il inclut un BOM UTF-8, sans lequel Excel affiche « Prêt » en « Prêt », et utilise le point-virgule comme séparateur, selon la convention des tableurs européens. Pour un autre format, liez votre implémentation sur DataFormat avec un bind. Ce contrat décrit lui aussi l'écriture et la lecture.
Au-delà de shop.backoffice.exchange.queue_threshold lignes (mille par défaut, zéro désactive la file), l'échange passe en file : une requête HTTP n'est pas faite pour parcourir cent mille lignes. Le fichier produit est alors servi par un lien signé et daté, et le compte rendu d'un import différé est envoyé par mail, faute de page où l'afficher.
Écrire sa propre datatable
Un package qui livre son propre écran d'admin étend AppDatatable et réutilise ses fabriques :
php
class SuppliersDatatable extends AppDatatable
{
public static function get(): Datatable
{
$datatable = Datatable::make(Supplier::class)->id('suppliers');
$datatable->columns()->column('name')->label(__('Nom'));
$datatable->columns()->column('is_active')
->label(__('Actif ?'))
->display([self::class, 'getBooleanLabel'])
->search(Search::make('select')->options(self::getBooleanSearch()));
$datatable->actions()->default('back.suppliers.edit', 'back.suppliers.destroy');
$datatable->columns()->defaultOrders(['name' => 'asc']);
static::setDefaultOptions($datatable);
static::setExportable($datatable, __('Fournisseurs'));
return static::extended($datatable);
}
}Ce que l'héritage vous apporte :
setDefaultColumns(): les colonnesupdated_at/created_at(recherche par date, affichagedatetime, non éditables), posées parextended()en fin de tableau. Ne les déclarez pas dans votre builder, elles arriveraient trop tôt — une colonne à laquelle vous donnez un libellé garde sa place, ce libellé et son rendu, et ne reçoit d'ici que l'interdiction de l'éditer en ligne ; une colonne déclarée sans libellé reçoit l'habillage commun. Pour changer leur libellé, reprenez plutôt la colonne aprèsextended(): c'est ainsi qu'OrdersDatatablerenomme « Création » en « Date de commande », etCartsDatatable« Mise à jour » en « Dernière activité ». « Mise à jour » est omise d'elle-même quand le modèle n'horodate pas ses modifications ; si votre modèle n'a pas non pluscreated_at, surchargez la méthode :extended()l'appelle parstatic::, c'est donc la vôtre qui s'applique ;setDefaultOptions(): visibilité des colonnes,ScrollX, réordonnancement des colonnes, en-tête fixe ;setExportable($datatable, $filename): bouton d'export Excel du tableau. Sans effet si le listing déclare un jeu de données échangeable : c'est alors l'échange qui répond (exporter et importer) ;setShowAction($datatable, $route): l'action d'un listing qu'on lit sans le modifier — un journal, un historique, les paniers. C'est une action nommée (vue communeback.dt_actions.show) à laquelle on passe la route à ouvrir (->route($route)) : la ligne s'ouvre, là où un référentiel proposerait de l'éditer ;setSelectable($datatable): la colonne de cases à cocher, posée automatiquement parextended()quand le listing a des actions groupées ;setActivationColumn($datatable, $model, $label = null): l'état actif/inactif et son filtre, pour tout modèle qui utiliseActivable. Le libellé vautActif ?par défaut ;CurrenciesDatatableetLanguagesDatatablepassent__('Active ?'),PagesDatatablepasse__('En ligne ?'). Le nom de la colonne vient du modèle (activeColumn()), pas d'une constante ;ORDER_HANDLE_CLASSetorderHandle(): la convention du glisser-déposer. Une colonne qui porte cette classe est remontée en tête de ligne et épinglée au défilement horizontal (ordonner) ;- les actions de ligne s'affichent en icônes, jamais en boutons texte. Le cœur remplace les vues du paquet (
datatable::actions.edit/.delete) par des icônes dont le libellé est porté pardata-tippy-content— comme les boutons du listing : effacer les filtres, colonnes affichées, exporter. Les infobulles sont rattachées à chaque rendu de la table (dtInitData), car ses lignes arrivent en AJAX après le chargement du script, et sur le document entier, car ces boutons vivent dans l'enveloppe de la table, pas dedans ; getBooleanLabel()/getBooleanSearch(): le couple Oui/Non pour l'affichage et pour leselect. Ils servent par exemple à la colonne d'activité desetActivationColumn()et à la colonnelocks_order(« Verrouille ? ») des statuts de commande ;getBadges($values, $model, $type = null, $callback = null): rendu en badges via le composantTag.
Une colonne calculée se déclare sans recherche ni tri. CartsDatatable pose ainsi total_ti avec ->search(false)->sort(false), puisque la valeur n'existe pas en base. PagesDatatable fait de même pour « Auteur », une relation non triable qu'elle charge d'avance (with('author')).
Une colonne peut aussi rendre du HTML, à condition de le déclarer. OrderStatusesDatatable pose name avec ->html() et ->display([static::class, 'displayName']). Cette méthode rend une pastille <x-tag type="status" :style="…"> colorée par OrderStatus::badgeStyle(). On passe un type nommé plutôt que vide, car le composant utiliserait sinon info et sa couleur de charte. OrdersDatatable fait de même pour le statut courant d'une commande (displayStatus()).
Un champ traduit ou contentable ne demande rien de particulier : les colonnes name et slug de PagesDatatable s'affichent et se cherchent dans la langue courante, grâce à l'intégration contentable/traduisible du paquet datatable.
Pas de fermeture dans une colonne
La datatable est sérialisée pour l'appel AJAX : display(), filter() et sort() prennent une méthode statique nommée, jamais une closure.
php
public static function displayTotalTi(string|array $value, Cart $cart): string;
public static function displayCartable(mixed $value, Cart $cart): string;
public static function filterCartable(Column $column, Builder $query, mixed $value): void;
public static function sortCartable(Column $column, Builder $query, string $dir): void;La direction de tri vient de la requête : sortCartable() la restreint strictement à asc/desc avant de l'insérer dans ses orderByRaw. Faites de même dans vos propres callbacks sort().
Les arguments passés après le callable de display() lui sont transmis. C'est ainsi que CustomersDatatable combine getBadges avec un type de badge et un formateur de libellé :
php
->display([self::class, 'getBadges'], ['user-role'], [self::class, 'getBadgesUserRoleName'])Les colonnes du cœur consomment les mêmes registres que le reste de l'application. La colonne status de ProductsDatatable tire ses options de app(ProductStatusRegistry::class)->list(), et son libellé de displayStatus(ProductStatus|string|null $status): string. Si elle reçoit un ProductStatus, cette méthode rend directement son label() ; sinon, elle résout la chaîne via app(ProductStatusRegistry::class)->get((string) $status)->label(). Un statut ajouté par un package apparaît donc dans le filtre sans toucher à la datatable. De même, CartsDatatable::displayTotalTi() formate le total via CurrencyService::price() (voir Prix & taxes).
Onglets & sections d'édition
Chaque écran d'édition rend une charpente au lieu d'écrire ses blocs en dur, par exemple <x-back.form-layout screen="back.taxes.edit" :context="['tax' => $tax]"/>. Cette charpente est déclarée par clé d'écran au FormLayoutRegistry. Un package peut, sans posséder la vue :
- ajouter son onglet (
tab()) ; - ranger ses sections (
section()) ; - déplacer une section du cœur dans l'onglet de son choix (
moveSection()).
tab() et section() acceptent aussi une condition facultative (when), évaluée par layout() sur le contexte de l'écran. Ce qui ne la remplit pas est écarté, et un onglet dont toutes les sections sont écartées disparaît aussi.
Trois familles de hooks restent disponibles pour ce qui n'a pas besoin de connaître la charpente : back.<ressource>.edit.tabs, back.<ressource>.edit.tab.<clé> (fin d'un onglet donné) et back.<ressource>.edit.sections. Voir les hooks & slots.
Pour valider un champ ainsi ajouté, contribuez ses règles via le FormRuleRegistry depuis votre provider : extend(SaveProductRequest::class, ['supplier_id' => 'required|exists:…']). AppFormRequest les fusionne automatiquement, sans surcharger la Form Request du cœur.
Réglages de boutique
Rendre un réglage administrable pose toujours le même problème : le code qui l'utilise lit config('shop.…'), et le stocker en base obligerait à réécrire tous ces points de lecture. Slab aborde le problème par l'autre bout : la base surcharge la configuration, et le code qui la lit ne change pas.
C'est le rôle du contrat SettingsService. Les réglages sont stockés par chemin de configuration (shop.catalog.popularity_window) et injectés dans config() au démarrage. Le fichier versionné reste la valeur par défaut.
Le contrat a quatre méthodes :
apply()injecte les réglages enregistrés par-dessus les valeurs des fichiers ;all()les renvoie (chemin de configuration → valeur) ;put()les enregistre et les applique aussitôt ;forget()vide le cache. Les réglages sont lus à chaque requête, et une requête SQL par page pour quelques valeurs ne se justifie pas.
Le CoreServiceProvider lie le contrat à DefaultSettingsService (singleton) et appelle apply() dans son boot(), avant tout le reste. L'écran back.settings.edit / back.settings.update n'a besoin que d'un contrôleur court : app(SettingsService::class)->put(...) sur ce que la requête a validé, après avoir rangé les images qu'elle porte (une image en réglage).
La valeur par défaut n'est jamais figée
put() supprime la ligne quand la valeur postée est identique à celle du fichier. Sans cela, une simple visite de l'écran figerait toute la configuration, et une valeur par défaut améliorée plus tard n'atteindrait jamais les boutiques qui n'avaient rien voulu changer.
La valeur de référence est celle que config() contenait avant la surcharge, mémorisée au moment où elle est écrasée. Elle n'est pas relue dans le fichier : la configuration d'un package est fusionnée au démarrage et n'est pas forcément publiée dans l'application, où son fichier n'existe donc pas toujours. Une valeur par défaut nulle est mémorisée comme les autres. La confondre avec une absence rendrait le réglage impossible à effacer.
Un worker ne redémarre pas entre deux jobs
Si les réglages n'étaient appliqués qu'au démarrage, un worker de file garderait les valeurs d'il y a plusieurs heures : un job utiliserait une valeur périmée, sans erreur ni trace. apply() est donc rappelé avant chaque job, par un écouteur de JobProcessing, pour le coût d'une lecture de cache.
C'est aussi pourquoi apply() sait annuler une surcharge : une clé qui n'est plus enregistrée reprend la valeur du fichier au lieu de persister jusqu'au redémarrage. Une implémentation tierce doit offrir la même garantie.
Rendre un réglage de package administrable
L'écran ne connaît pas les réglages qu'il affiche : sa structure vient du FormLayoutRegistry (onglets & sections) et sa validation du FormRuleRegistry. Le cœur n'y affiche lui-même que quatre réglages :
shop.catalog.popularity_window;shop.catalog.novelty_window;shop.registration.mode(l'enumRegistrationMode, voir Comptes & autorisations) ;shop.notifications.admin_email.
Un package ajoute les siens en trois étapes.
1. Une vue de section, calquée sur celles du cœur (back.settings.sections.catalog). La valeur affichée est lue dans config(), et les champs portent l'id du formulaire de l'écran, comme le bouton d'enregistrement placé dans l'en-tête :
php
<x-back.section :title="__('Délais')">
<x-back.field-group>
<x-form.fields.input type="number"
name="delivery[cutoff_hour]"
:label="__('Heure limite de commande')"
:value="config('delivery.cutoff_hour')"
min="0" max="23"
form="settings_form"/>
</x-back.field-group>
</x-back.section>2. La déclaration de la section sur l'écran, et 3. sa règle de validation, depuis le provider du package :
php
public function boot(): void
{
$this->loadViewsFrom(__DIR__.'/../resources/views', 'delivery');
$this->app->make(FormLayoutRegistry::class)->section(
'back.settings.edit', 'delivery', 'delivery::settings.delays', 'general', priority: 30,
);
$this->app->booted(function (): void {
$this->app->make(FormRuleRegistry::class)->extend(SaveSettingsRequest::class, [
'delivery.cutoff_hour' => ['nullable', 'integer', 'between:0,23'],
]);
});
}Crochets dans le champ, points dans la règle
PHP transforme les points en tirets bas dans les noms de champs. Le formulaire poste donc delivery[cutoff_hour], que put() aplatit (Arr::dot) en clé delivery.cutoff_hour. C'est cette forme pointée qu'attendent la règle de validation et la table settings.
SaveSettingsRequest::rules() est vide par conception : un champ sans règle contribuée n'est pas validé, donc pas enregistré. Un champ ajouté dans une requête forgée n'arrive donc jamais en base.
Une règle sometimes reste utile pour un champ obligatoire qui n'est pas toujours présent. C'est ce que fait le cœur pour shop.registration.mode : un formulaire sans sa section n'a pas à le renvoyer, mais un formulaire qui l'inclut ne peut pas le vider.
Des valeurs simples
Un réglage est un nombre, une chaîne ou un booléen. Une liste administrable obligerait à savoir où s'arrête le chemin de configuration et où commence la valeur. Le jour où il en faudra une, ce sera un type déclaré, pas un tableau posté sans contrôle.
Un réglage peut en revanche changer de forme : un texte devenu traduisible passe par exemple de …announcement à …announcement.fr et …announcement.en. Avant d'enregistrer un chemin, put() retire donc les réglages qui le chevauchent : celui qui le contient (a.b pour a.b.fr) et ceux qu'il contient (a.b.fr pour a.b). Sinon, l'ancienne valeur resterait à côté des nouvelles : selon l'ordre d'application, elle les écraserait, ou survivrait à leur effacement. Ce ménage a lieu même quand la nouvelle valeur est celle du fichier.
Une image en réglage
Un réglage peut aussi être une image — le logo d'une boutique. Le réglage n'en garde que le chemin sur le disque public : c'est toujours une valeur simple. Le champ x-back.setting-image reprend le dessin des autres champs image (glisser-déposer, aperçu, suppression) ; il poste le fichier sous …[file] et la demande de retrait sous …[delete].
php
<x-back.setting-image name="delivery[carrier_logo]"
setting="delivery.carrier_logo"
:label="__('Logo du transporteur')"
form="settings_form"/>Le package déclare les règles des deux clés, et la valeur par défaut null dans sa configuration :
php
'delivery.carrier_logo.file' => ['nullable', 'image', 'max:2048'],
'delivery.carrier_logo.delete' => ['nullable', 'in:1'],Le contrôleur des réglages range le fichier sous settings/ et enregistre son chemin, efface l'ancien fichier quand on le remplace ou le retire, et fait passer un nouveau fichier avant une demande de retrait postée avec lui. Pour afficher l'image : Storage::disk('public')->url(config('delivery.carrier_logo')).
Substituer le stockage
Le contrat permet à une implémentation tierce de ranger les réglages ailleurs (un fichier, un service distant) sans changer le code qui les lit, puisque tout passe par config(). Comme apply() est appelé dans le boot() du cœur, le remplacement se déclare dans register() :
php
public function register(): void
{
$this->app->singleton(SettingsService::class, RemoteSettingsService::class);
}L'implémentation par défaut assume deux comportements, qu'une autre implémentation doit reproduire :
- les réglages sont mis en cache sans expiration, et ce cache est vidé à chaque écriture. Une écriture directe sur le modèle
Settingexige donc unforget()explicite, comme le font les tests du cœur ; - le démarrage est tolérant : tant que la table n'existe pas (première installation, migrations pas encore jouées), les valeurs des fichiers sont conservées en silence plutôt que d'empêcher l'application de démarrer.
Autorisations
L'accès est protégé par auth + CanAccessBackoffice. Les rôles et permissions (UserRole, Permission) sont des enums ; vous ajoutez vos propres gates via Gate::define(...). Voir Comptes & autorisations.
Les listes d'utilisateurs suivent ces rôles au lieu de les figer. TeamDatatable filtre sur withRole('team'). CustomersDatatable retient tous les rôles pour lesquels UserRole::isCustomer() est vrai. Elle n'affiche la colonne « Roles » que s'il en existe plus d'un, et son filtre combine les withRole() dans filterUserRole(). Un rôle client supplémentaire y apparaît donc automatiquement.