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 : package installé = feature active.
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\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);
// 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
$this->app->make(RouteContributionRegistrar::class)
->back(fn () => Route::resource('loyalty', LoyaltyController::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.
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);
}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. Voir les tests *BackofficeTest des packages officiels pour le modèle.