Apparence
Créer un package
Une feature Slab — un mode de paiement, un transporteur, un programme de fidélité — est un package Composer qui s'enregistre seul et contribue aux points d'extension du cœur. Le cœur n'en sait jamais rien. Ce guide construit le squelette d'un package, sur le modèle des packages officiels (slab/promotion, slab/carrier, slab/payment).
Le composer.json
json
{
"name": "acme/loyalty",
"description": "Programme de fidélité pour Slab.",
"type": "library",
"require": {
"php": "^8.4",
"laravel/framework": "^13.0",
"slab/framework": "^1.0"
},
"autoload": {
"psr-4": {
"Acme\\Loyalty\\": "src/",
"Acme\\Loyalty\\Database\\Factories\\": "database/factories/"
}
},
"extra": {
"laravel": {
"providers": [
"Acme\\Loyalty\\LoyaltyServiceProvider"
]
}
}
}Trois points décisifs :
requireslab/framework: la seule dépendance vers l'écosystème. Un package ne dépend jamais d'un autre package.autoload.psr-4mappesrc/etdatabase/factories/.extra.laravel.providersauto-découvre le provider. Sans ce bloc, installer le package ne ferait rien. Aucun flag de feature : sur Slab, une feature s'exécute parce que son package est installé.
La structure
acme/loyalty/
├── composer.json
├── database/{factories,migrations}/
├── resources/views/
└── src/
├── Models/ Enums/ Http/ Datatables/ Modifiers/
└── LoyaltyServiceProvider.phpLe service provider
Tout se joue dans le boot() : il charge les ressources du package, puis greffe ses contributions sur les contrats du cœur. Chaque ligne ci-dessous est une mécanique :
php
namespace Acme\Loyalty;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Slab\Framework\Core\BackMenu\Contracts\BackMenuRegistry;
use Slab\Framework\Core\Pricing\Contracts\PriceModifierRegistry;
use Slab\Framework\Core\Pricing\Contracts\TotalsModifierRegistry;
use Slab\Framework\Core\Routing\Contracts\RouteContributionRegistrar;
use Slab\Framework\Models\Product;
class LoyaltyServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Ressources possédées par le package
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
$this->loadViewsFrom(__DIR__.'/../resources/views', 'loyalty'); // → vues `loyalty::…`
// Un maillon de PIPELINE de prix
$this->app->make(PriceModifierRegistry::class)
->add(LoyaltyPriceModifier::class, priority: 60);
// Sa contrepartie « totaux » : les lignes de détail affichées entre « produits » et « total »
$this->app->make(TotalsModifierRegistry::class)
->add(LoyaltyTotalsModifier::class, priority: 60);
// Une RELATION greffée sur un modèle du cœur
Product::resolveRelationUsing('loyaltyRewards',
fn (Product $product) => $product->hasMany(LoyaltyReward::class));
// Un écran de back-office : REGISTRES routes + menu
$registrar = $this->app->make(RouteContributionRegistrar::class);
$registrar->back(fn () => Route::resource('loyalty', LoyaltyController::class));
// Côté front, la contribution est posée dans le groupe localisé **avant** les
// routes attrape-tout (slug) : vos URLs priment.
$registrar->front(fn () => Route::get('loyalty', RewardsController::class));
$this->app->make(BackMenuRegistry::class)
->add('catalog.loyalty', __('Fidélité'),
route: 'back.loyalty.index', icon: 'loyalty', parent: 'catalog', priority: 50);
}
}Pas de méthode
register()dans la plupart des packages : il n'y a rien à binder, tout passe par les contrats déjà liés par le cœur. Voir les registres, les pipelines et modèles & relations.
Les contributions se déclarent au boot()
C'est vrai en particulier pour les routes : le RouteContributionRegistrar collecte les callbacks avant que les fichiers de routes du cœur ne soient chargés, ce qui les rend compatibles avec route:cache. Un enregistrement plus tardif arriverait après coup.
Vos priorités sont des défauts, pas des verrous
Le projet final réordonne sans vous redéfinir : config/pricing.php pour les modificateurs de prix (clé totals_priorities pour ceux de totaux), config/back_menu.php ou BackMenuRegistry::prioritize() pour le menu. Une clé de menu inconnue y est ignorée — désinstaller votre package ne casse pas la construction du menu.
Les deux classes enregistrées plus haut implémentent chacune un contrat d'une seule méthode :
php
use Closure;
use Slab\Framework\Core\Pricing\Contracts\PriceModifier;
use Slab\Framework\Core\Pricing\Contracts\TotalsModifier;
use Slab\Framework\Core\Pricing\PriceContext;
use Slab\Framework\Core\Pricing\TotalsContext;
use Slab\Framework\Core\Pricing\TotalsLine;
class LoyaltyPriceModifier implements PriceModifier
{
public function handle(PriceContext $context, Closure $next): PriceContext
{
// Mute $context->price (HT) et/ou $context->trace, puis passe la main —
// `$next($context)` doit être appelé même sans effet.
return $next($context);
}
}
class LoyaltyTotalsModifier implements TotalsModifier
{
/** @return list<TotalsLine> */
public function lines(TotalsContext $context): array
{
// Zéro, une ou plusieurs lignes de détail.
return [];
}
}Un modificateur de prix ignore la taxe (maillon cœur de fin de chaîne) et les autres maillons. Un modificateur de totaux, lui, reçoit dans son contexte les lignes déjà contribuées : il peut y répondre en posant sa contrepartie, jamais en réécrivant la ligne d'un autre.
Ajouter un maillon ou remplacer l'orchestration
Enregistrer un modificateur est le cas courant. Pour changer la mécanique entière, on rebinde le contrat — PricePipeline (priceFor(PriceContext): Price) ou TotalsBuilder (forCart(Cart): Totals / forOrder(Order, bool $currency = false): Totals) — depuis un package ou la couche applicative, sans forker le cœur. Notez que forOrder() ne recalcule rien : il relit le snapshot figé dans price_components. Vos lignes de totaux y sont conservées telles quelles.
Commandes console & tâches planifiées
Pas de registre dédié : un package enregistre ses commandes Artisan et planifie ses tâches avec les mécanismes natifs de Laravel, depuis son provider. Le cœur fait exactement pareil (la mise à jour des taux de change est planifiée ainsi).
php
use Illuminate\Console\Scheduling\Schedule;
public function boot(): void
{
// Commandes Artisan du package
$this->commands([
\Acme\Loyalty\Console\ExpireRewards::class,
]);
// Tâche planifiée (production) — comme le cœur pour les taux de change
$this->callAfterResolving(Schedule::class, function (Schedule $schedule): void {
$schedule->command('loyalty:expire-rewards')->daily();
});
}TIP
Le scheduler de l'application (routes/console.php) reste réservé au projet : une feature se planifie elle-même via son provider, jamais en éditant le skeleton.
Renforcer les gardes du back-office
Le back-office passe par le groupe de middleware nommé back (par défaut auth + CanAccessBackoffice). Un package qui doit ajouter une garde transverse — 2FA, liste blanche d'IP, journal d'audit — l'ajoute au groupe depuis son provider, sans toucher au fichier de routes du cœur :
php
use Illuminate\Support\Facades\Route;
public function boot(): void
{
// S'applique à tout le back-office (toutes les routes du groupe `back`).
Route::pushMiddlewareToGroup('back', \Acme\Security\Http\Middleware\EnforceTwoFactor::class);
}Les routes que vous contribuez via RouteContributionRegistrar::back() héritent de ce groupe : elles s'exécutent dans le contexte complet (préfixe du back-office, nom back., middlewares), garde comprise.
Le schéma vous appartient
Le cœur ne crée aucune table pour votre feature. Vos migrations, chargées par loadMigrationsFrom, créent vos tables — avec des clés étrangères vers le cœur, jamais l'inverse :
php
Schema::create('loyalty_rewards', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained('products')->cascadeOnDelete();
$table->integer('points');
$table->timestamps();
});Datez la migration avant celles du cœur
Préfixez le timestamp (2024_01_01_…) pour que vos clés étrangères se posent après la création des tables du cœur. C'est clean slate : aucune colonne du cœur n'est modifiée.
Tester votre package
Chaque contribution se teste depuis le skeleton (Pest) : qu'une route est bien contribuée, qu'une entrée de menu apparaît, qu'un modifier transforme le prix attendu. Les registres se relisent directement — RouteContributionRegistrar::backRoutes() / frontRoutes(), BackMenuRegistry::tree(), PriceModifierRegistry::ordered() / TotalsModifierRegistry::ordered() — ce qui permet d'assurer la présence et le rang d'une contribution sans passer par une requête HTTP. Voir les tests *BackofficeTest des packages officiels pour le modèle.