Apparence
Créer un thème
Dans Slab, tout le front est un package : slab/frontend fournit les contrôleurs, les routes front.*, les vues, les view-models, les assets et le tunnel de commande. Le cœur n'expose, lui, que le domaine et un contrat de noms que le front doit honorer. Personnaliser la présentation se fait à trois niveaux, du plus léger au plus radical.
Niveau 1 — surcharger des vues depuis l'application
Le frontend charge ses vues sous le namespace frontend::. Votre application a la priorité : déposez une vue de même chemin sous resources/views/vendor/frontend/ pour la remplacer.
resources/views/vendor/frontend/
├── layouts/front.blade.php ← remplace le layout
├── products/show.blade.php ← remplace la fiche produit
└── components/front/header.blade.phpAucune configuration : la vue de l'app l'emporte. Vous ne touchez jamais au code du frontend, et vous gardez ses mises à jour pour tout ce que vous ne surchargez pas. Détail : Surcharger une vue.
C'est le bon niveau pour un projet. Il ne se distribue pas : ce qui vit dans resources/views appartient à l'application, pas à un paquet installable.
Niveau 2 — un thème en package
Pour distribuer un habillage — le vendre, l'installer sur plusieurs projets, le versionner —, il faut un package. Un thème n'est alors ni une surcharge d'application ni un front de remplacement : il passe devant slab/frontend et ne redéfinit que ce qu'il veut.
La cascade de vues est app > thème > frontend : l'application garde le dernier mot, le thème passe devant le front par défaut, et tout ce qu'il ne redéfinit pas continue d'être servi — et corrigé — par lui.
Déclarer son thème
Une seule chose à faire, dans le boot() du provider du package :
php
use Slab\Frontend\Theme\Contracts\ThemeRegistry;
use Slab\Frontend\Theme\Theme;
public function boot(): void
{
$this->app->make(ThemeRegistry::class)->register(new Theme(
name: 'white',
viewPath: __DIR__.'/../resources/views',
styles: ['packages/mon-theme/resources/scss/theme.scss'],
scripts: [
'packages/mon-theme/resources/js/theme.js',
// Le manifeste est remplacé en entier : notifications et sélecteurs sont
// des entrées à elles seules, à reprendre.
'packages/framework/resources/js/components/toast.js',
'packages/framework/resources/js/components/form/fields/select.js',
],
));
}Tout suit : les vues entrent dans la cascade, les composants anonymes du dossier components/ du thème passent devant ceux du front, et le manifeste d'assets remplace celui par défaut.
Ce qu'un thème redéfinit
| Quoi | Comment |
|---|---|
une page (frontend::products.show) | même chemin sous le viewPath du thème |
un composant anonyme (<x-front.header/>) | même chemin sous components/ du thème |
un view-model (<x-front.cart/>) | Blade::component(MonCart::class, 'front.cart') dans un booted() |
| le rendu d'un filtre de catalogue | CatalogFilterRegistry::view('price', …) — le calcul reste au cœur |
| les assets | les entrées styles / scripts du thème |
| un libellé | ses propres traductions, ou lang/vendor/frontend/ côté application |
Les view-models s'enregistrent dans booted()
Le front pose ses alias <x-front.*> pendant son boot(), et l'ordre de boot des packages n'est pas un contrat. Un thème qui veut substituer un view-model le fait dans $this->app->booted(…) : il passe alors forcément en dernier, donc il gagne.
Choisir le thème servi
Par défaut, le paquet installé gagne : une installation qui ne porte qu'un thème n'a rien à régler. Deux thèmes présents se départagent par leur priority ; frontend.theme (ou FRONT_THEME) tranche en nommant celui qu'on sert — utile à un projet qui embarque plusieurs habillages. Un nom qui ne désigne rien rend le front par défaut : mieux vaut un habillage reconnaissable qu'un thème pris au hasard.
Le choix lui-même est un point d'extension : une implémentation maison de ThemeRegistry peut le faire dépendre du domaine, du compte ou d'un réglage en base, sans que le front en sache rien.
Le manifeste d'assets
Les layouts ne codent aucune entrée Vite : ils chargent $themeStyles et $themeScripts, que le front tire du registre et partage avec toute vue frontend::layouts.*. Un thème qui déclare des styles remplace ceux du front ; celui qui n'en déclare pas les garde — un habillage peut n'être fait que de vues. Une application peut aussi surcharger frontend.assets sans écrire de thème du tout.
Un thème qui déclare des scripts les déclare tous
Le remplacement est total, entrée par entrée. Le manifeste par défaut en compte trois : packages/frontend/resources/js/front.js, packages/framework/resources/js/components/toast.js, les notifications, et packages/framework/resources/js/components/form/fields/select.js, Tom Select. Un thème qui déclare ses propres scripts doit redéclarer les deux derniers, sans quoi le site redevient muet pour tout ce qui passe par un script — le message d'une soumission refusée, celui d'une modale — et ses listes déroulantes retombent au select natif : ni le pays du tunnel, ni un filtre réglé en liste déroulante ne sont repris, et les feuilles qui habillent .ts-control attendent dans le vide. Les importer depuis le bundle du thème ne les rattrape pas : ces modules sont déjà des entrées du build (le back-office s'en sert), et Rollup n'embarque pas dans un bundle ce qu'il émet par ailleurs.
Un thème qui refait son layout charge le manifeste de la même façon : @vite($themeStyles) dans le <head>, @vite($themeScripts) en fin de page.
Le glob Vite du skeleton ingère packages/*/resources/{scss,js} : un thème n'a donc aucune configuration de build à faire écrire au projet qui l'installe.
Le cache de fragments
Les blocs immobiles d'une vitrine — bandeaux, réassurance, pied de page — se rendent une fois et se resservent tels quels : @cached('clé', ['étiquette']) … @endcached met en cache le HTML produit, requêtes comprises — un fragment en cache n'exécute même pas son bloc. La directive ne s'appelle pas @fragment : ce nom appartient déjà à Blade.
L'oubli suit les étiquettes. Chaque écriture annonce les siennes (FragmentTags remonte un contenu jusqu'à ce qu'il habille : homepage, menu, page:12…) et ne vide que les fragments qui les portent — enregistrer un produit ne touche pas la mosaïque de l'accueil. Un package qui porte ses propres données d'affichage appelle FragmentCache::flush() avec ses étiquettes après les avoir enregistrées : le cœur ne connaît pas ses tables.
Coupez-le pendant l'intégration
FRONT_FRAGMENT_CACHE=false (clé frontend.fragments.enabled) fait apparaître chaque changement de vue sans vider le cache à la main ; FRONT_FRAGMENT_CACHE_TTL en règle la durée de vie, 86400 secondes par défaut.
Les consignes aux moteurs
Chaque page porte sa balise robots et, si elle s'indexe, son adresse canonique (Slab\Frontend\Seo\Indexing). Le front ne les écrit dans aucun layout : il les pose par le hook front.head, comme les données structurées du site, les balises de partage et la balise de validation de la Search Console. Un thème qui refait son layout n'a donc rien à recopier, à condition de garder <x-hook name="front.head"/> dans son <head>.
| Page | Consigne (frontend.robots.*) |
|---|---|
| une page de la vitrine | public : index, follow, aperçu des images en grand compris |
une page désignée par private_routes — compte, connexion, inscription, panier, tunnel, commande, recherche | private : noindex, follow |
| toute page d'une installation qui ne s'indexe pas — par défaut, tout ce qui n'est pas la production (staging, préprod) | closed : noindex, nofollow |
frontend.robots.indexable (ou FRONT_INDEXABLE) tranche cette dernière ligne : laissé vide, seule la production s'indexe. Une démonstration publique qui veut être trouvée le déclare (true), une production qu'on garde cachée aussi (false).
frontend.robots.private_routes liste des motifs de noms de route (front.account.*, front.checkout.*…) : un package qui ajoute une page de ce genre s'ajoute à la liste. Une page sans route, une erreur par exemple, compte comme privée. Une page privée n'a pas d'adresse canonique. Celle d'une page indexable ne garde ni tri ni filtre : seule la pagination reste.
Dans une boutique de plusieurs langues, une page qui n'est pas privée annonce aussi ses versions dans chacune (<link rel="alternate" hreflang="…">, Indexing::alternates()), elle comprise, et x-default sur la langue par défaut. Les adresses sont celles du sitemap : la langue par défaut sans son préfixe, le slug d'un modèle dans la langue visée. Pas celles du sélecteur de langue, dont le lien vers la langue par défaut garde son préfixe et redirige : un moteur écarte une version qui redirige. Une boutique d'une seule langue n'en porte aucune.
Toute page qui n'est pas privée dit aussi ce qu'elle montre quand on partage son adresse : les balises Open Graph et la carte de X (Slab\Frontend\Seo\OpenGraph) reprennent son titre, sa description, son adresse canonique et l'image de ce qu'elle présente — la photo d'un produit, le bandeau d'un rayon, le logo d'une marque. Une page privée n'en porte pas. Deux choses reviennent au thème, à déclarer au boot() de son provider : le nom du site (siteNameUsing(), sinon app.name) et l'image d'une page qui n'en a pas (fallbackImageUsing()), deux closures qui rendent une chaîne ou null.
Open Graph veut une langue et un pays : fr devient fr_FR. Une langue dont le pays ne s'écrit pas comme elle trouve le sien dans frontend.open_graph.locales (en → en_US, zh → zh_CN…) ; un projet qui sert une autre langue de ce genre l'y ajoute.
La balise de validation de la Search Console (google-site-verification), elle, ne revient pas au thème : son code est un réglage du back-office (« Configuration › Boutique › Référencement », clé frontend.verification.google), propre à chaque boutique, où le marchand colle la balise entière ou son seul code.
Niveau 3 — remplacer le front entièrement
Le front étant un package remplaçable, vous pouvez écrire votre propre front à la place de slab/frontend — refonte totale, rendu headless, autre moteur. Le package doit alors, dans son provider : charger ses vues sous un namespace stable, enregistrer ses view-models, déclarer ses routes front et accueillir celles des autres packages, fournir ses assets, et honorer les contrats du cœur.
Les contrats à honorer
Un front complet doit garder les noms de routes front.* sur lesquels les packages s'appuient — le cœur les publie dans FrontRouteContract::groups() —, appliquer leurs routes contribuées et exposer les slots qu'énumère FrontSlotContract::groups() (front.head, front.product.summary.after, front.cart.totals.after…). C'est ce qui permet à un package de fonctionner quel que soit le front.
Un package ne déclare pas ses routes de son côté : il les contribue au RouteContributionRegistrar du cœur, et le front les applique dans son groupe localisé avant les routes attrape-tout (slug), de sorte qu'elles priment. Le même registre porte l'autre réalme, le back-office (back()), appliqué lui dans le groupe du cœur. Les contributions s'enregistrent au boot(), donc avant le chargement des fichiers de routes — elles passent dans route:cache.
php
use Slab\Framework\Core\Routing\Contracts\RouteContributionRegistrar;
// Dans le boot() du provider du package :
$this->app->make(RouteContributionRegistrar::class)->front(function (): void {
Route::get('…', WishlistController::class)->name('front.wishlist.show');
});Un front de remplacement, lui, applique ces contributions dans son propre groupe front, avant ses attrape-tout :
php
foreach ($this->app->make(RouteContributionRegistrar::class)->frontRoutes() as $routes) {
$routes();
}Côté vues, exposez les points d'injection avec <x-hook name="…" :context="[…]"/> dans vos layouts ; les packages y poussent leurs partiels via le ViewHookRegistry. Les hooks d'écran viennent en plus, sans rien déclarer : toute route nommée ouvre <route>.start et <route>.end (ScreenHook).
Le tunnel de commande
Le tunnel d'étapes appartient au front, pas au cœur : le frontend possède le contrat CheckoutFlow, son DefaultCheckoutFlow et le registre des étapes (CheckoutStepRegistry). Réutilisez-les ou fournissez les vôtres. Les features (transport, paiement) n'exposent que des options en donnée (CheckoutOptionProvider) que le front rend génériquement — voir Commande & checkout.
Sur le tunnel en une page (SinglePageCheckoutFlow), quatre marques de balisage forment le contrat entre vos vues et le script du front. Elles sont le seul lien : rien n'est nommé côté script, et une étape contribuée par un package en profite sans qu'une ligne bouge.
| Marque | Sur quoi | Ce qu'elle obtient |
|---|---|---|
data-checkout-live="<clé>" | un bloc dont le contenu dépend des montants — le récapitulatif, le bouton | il est refait quand un choix change les totaux, sans recharger la page |
data-checkout-refresh | un champ qui pèse sur les montants — le mode d'expédition, l'adresse de livraison | son changement recompose la page (checkout.refresh) |
data-checkout-blocker | ce qui empêche de commander sans qu'un champ puisse le dire — un carnet d'adresses vide | le bouton s'annonce inerte, et y ramène |
data-checkout-panel | ce qu'une option montre une fois choisie (content / slot) | il s'ouvre et se referme avec sa puce |
Le serveur rend déjà tout cela juste à chaque chargement : ces marques n'évitent qu'un rechargement. Marquez ce qui est obligatoire (required) — sauf dans un bloc que votre mise en page replie : un champ obligatoire qu'on ne montre pas bloque la soumission sans pouvoir le désigner, le navigateur ne pouvant donner le focus à ce qu'il ne montre pas. La validation serveur, elle, sait ce qu'elle exclut.
Un bouton inerte reste cliquable
Tant qu'il manque quelque chose, le front pose aria-disabled sur le bouton de soumission — jamais disabled : un bouton qu'on ne peut pas presser ne dit jamais ce qui manque. Son clic ne commande pas, il amène à ce qui manque. Habillez donc l'état [aria-disabled="true"].
Un thème habille le front, pas le back-office
Les écrans d'administration ne se thèment pas : ils s'étendent par registres — FormLayoutRegistry (onglets et sections d'un formulaire, moveSection() compris, et aside: true pour ranger une section dans la colonne étroite d'un onglet partagé en deux ; l'argument columns: de tab() est déprécié et n'a plus d'effet), DatatableExtensionRegistry (colonnes, actions et options d'un listing existant), BulkActionRegistry (actions groupées, rangées sous l'id du listing) et ViewHookRegistry (partiels). Voir les points d'extension.
Quel niveau choisir ?
| Besoin | Approche |
|---|---|
| Changer le style, quelques vues, sur un projet | Niveau 1 : surcharge sous vendor/frontend/ |
| Distribuer un habillage, le versionner, l'installer ailleurs | Niveau 2 : un package de thème |
| Remplacer entièrement le front (autre rendu, headless…) | Niveau 3 : votre package de front |
Dans tous les cas, le domaine reste intact : un thème ne touche jamais au cœur ni aux features. Et il ne recopie jamais une vue « en attendant » — un blocage d'intégration est un point d'extension à ouvrir dans slab/frontend, pas un fork.