Skip to content

Comptes & autorisations ​

Slab distingue les clients (User / Guest) et l'équipe (accès back-office). Les autorisations s'appuient sur spatie/laravel-permission et des enums.

Par où l'étendre ​

Je veux…Point d'extensionMécanique
réagir à la création d'un compteUserRegisteredévénements
réagir à la validation d'une inscriptionUserApprovedévénements
réagir à un changement d'e-mailUserEmailChangedévénements
changer la politique d'inscriptionRegistrationServiceconteneur (liaison d'interface)
choisir la surface d'un lien de mot de passePasswordLinkRealmconteneur (instance de requête)
compléter ce que l'enregistrement d'un compte écritSaveHookRegistryregistres
ajouter une autorisation customGate::define(...)conteneur / Laravel
ajouter un rôle ou une permissionAccessControlRegistryregistres
changer la résolution du panier selon le compteCurrentCartResolverrésolveurs

Cycle de vie du compte ​

Trois événements (Slab\Framework\Core\Account\Events) découplent l'onboarding de la persistance. Tous portent le compte concerné : public readonly User $user (Slab\Framework\Models\User).

  • UserRegistered — un compte vient d'être créé. Un listener envoie la notification de création de mot de passe ; une extension (SSO, import en masse, validation B2B…) s'y greffe ou s'y substitue sans toucher au cœur.
  • UserApproved — un administrateur vient de valider une inscription en attente. C'est là — et pas à la création du compte — que l'onboarding commence quand les inscriptions sont soumises à validation : inviter quelqu'un à choisir son mot de passe avant de savoir s'il pourra entrer serait lui promettre une porte qu'on n'a pas encore décidé d'ouvrir.
  • UserEmailChanged — l'e-mail d'un compte existant a changé. L'observer a déjà invalidé le mot de passe (l'e-mail doit être re-vérifié) ; un listener notifie l'utilisateur pour qu'il le redéfinisse.

Inscription : ouverte ou soumise à validation ​

Qui peut créer un compte, et à quelles conditions il sert, est une politique — pas un enchaînement d'if dans un contrôleur. Elle est portée par RegistrationService (Slab\Framework\Core\Account\Contracts), implémenté par défaut par DefaultRegistrationService :

  • mode(): RegistrationMode — le mode en vigueur, lu par le cœur via RegistrationMode::current() (Slab\Framework\Models\Enums).
  • register(array $attributes): User — inscrit un compte, validé ou non selon le mode. L'appelant n'a pas à trancher : c'est tout l'intérêt du réglage.
  • approve(User $user): void — valide une inscription en attente, puis émet UserApproved.
php
use Slab\Framework\Core\Account\Contracts\RegistrationService;

$user = app(RegistrationService::class)->register($request->validated());

Le mode, et la porte qu'il ferme ​

mode() n'est pas un détail interne de register() : c'est la valeur que tout le parcours interroge. Le mode vit dans l'enum RegistrationMode (Slab\Framework\Models\Enums) et se lit via RegistrationMode::current(), qui résout config('shop.registration.mode') — un réglage administrable depuis le back-office, pas une constante de code.

Caslabel()allowsRegistration()requiresApproval()
OPEN (open)Libretruefalse
APPROVAL (approval)Soumise à validationtruetrue
CLOSED (closed)Ferméefalsefalse

INFO

current() fait tryFrom() et retombe sur __default (OPEN) : une valeur inconnue en configuration n'a pas le pouvoir de fermer la boutique parce qu'on a mal orthographié un réglage. Le mode ne décide que de l'inscription — la connexion et la commande en invité restent ouvertes, un compte existant garde sa porte.

Le mode CLOSED n'est pas appliqué par une déclaration de route conditionnelle mais par le middleware EnsureRegistrationIsOpen (alias registration.open, enregistré dans RouteServiceProvider), posé sur front.users.create et front.users.store : le mode change à chaud, alors que les routes se mettent en cache au déploiement. Et il rend 404, pas 403 — une inscription fermée n'est pas un droit refusé, c'est une porte qui n'existe pas.

Point à retenir pour qui étend : le middleware demande le mode au service (app(RegistrationService::class)->mode()), jamais à l'enum. Redéfinir mode() déplace donc aussi la porte du front, sans toucher aux routes.

Ce que fait l'implémentation par défaut ​

L'implémentation par défaut fait trois choses qui méritent d'être connues avant d'être remplacées. Elle renseigne language_id et currency_id depuis le référentiel (Language::default(), Currency::default()) quand ils manquent, plutôt que de laisser le compte tomber sur les défauts de la table. Elle pose approved_at avant l'enregistrement — null si $this->mode()->requiresApproval(), now() sinon — parce que l'observer émet UserRegistered au created et que le listener qui invite à choisir un mot de passe doit déjà savoir si le compte servira. Et le compte est toujours enregistré : une inscription refusée à l'entrée ne laisserait rien à valider.

approve() est un cliquet : sans effet si $user->isApproved(), il ne réécrit pas la date et ne renvoie pas d'invitation. Il forceFill ensuite approved_at puis dispatche UserApproved.

Étendre sans réécrire la politique ​

DefaultRegistrationService n'est pas final : le plus souvent, on l'étend pour ajouter une décision, et on laisse le reste (langue, devise, approved_at, UserRegistered) au cœur.

php
namespace App\Account;

use Slab\Framework\Core\Account\DefaultRegistrationService;
use Slab\Framework\Models\User;

class PartnerRegistrationService extends DefaultRegistrationService
{
    /**
     * @param  array<string, mixed>  $attributes
     */
    public function register(array $attributes): User
    {
        $user = parent::register($attributes);

        // La boutique reste « soumise à validation » pour tout le monde ; les domaines connus
        // passent devant, sans qu'un administrateur ait à cliquer.
        if ($this->mode()->requiresApproval() && $this->isPartner($user->email)) {
            $this->approve($user);
        }

        return $user;
    }

    private function isPartner(string $email): bool
    {
        return str_ends_with($email, '@partenaire.example');
    }
}

La liaison est celle du conteneur, dans le ServiceProvider du package :

php
$this->app->bind(RegistrationService::class, PartnerRegistrationService::class);

Ni le contrôleur front, ni l'écran des clients, ni le middleware n'ont à savoir que la règle a changé.

WARNING

approve() émet UserApproved, sur lequel SendNewPasswordNotification fait partir l'invitation à définir un mot de passe — et le lien est composé par PasswordLinkRealm d'après la requête en cours (voir Où mène le lien de mot de passe). Valider depuis une commande ou un import, hors requête front, demande donc de lier explicitement PasswordLinkRealm::front() avant l'appel, comme le fait l'écran des clients.

TIP

Un package qui crée un compte doit passer par register(), pas par User::create() : l'observer valide d'office (approved_at = now()) tout compte dont les attributs ne portent pas la clé approved_at — c'est le défaut sûr pour un seeder ou un import, mais il court-circuite la politique d'inscription.

Réagir à la validation ​

UserApproved ne part qu'à la transition : approve() étant un cliquet, revalider un compte déjà validé n'émet rien. Le cœur y branche un seul listener, SendNewPasswordNotification — le même qui écoute UserRegistered et UserEmailChanged, et qui sort immédiatement si ! $event->user->isApproved() : c'est ce garde-fou qui décale l'invitation de la création vers la validation.

php
use Illuminate\Support\Facades\Event;
use Slab\Framework\Core\Account\Events\UserApproved;

Event::listen(UserApproved::class, function (UserApproved $event): void {
    // approved_at est déjà posé et enregistré : le compte est validé pour de bon.
    OnboardApprovedCustomer::dispatch($event->user);
});

Déléguez tout travail long à un job plutôt que de le faire dans le listener : approve() s'exécute dans la requête du back-office.

Tous les comptes ne passent pas par là

En mode OPEN, comme pour les comptes créés par un seeder, un import ou le back-office, le compte naît validé et aucun UserApproved n'est émis — seul UserRegistered part. Un traitement qui doit couvrir tout compte utilisable écoute donc les deux, et se garde par $event->user->isApproved(), exactement comme le fait le listener du cœur.

Où mène le lien de mot de passe ​

Les deux surfaces montent le même groupe de routes d'authentification, sous deux noms (front.auth.* et back.auth.*) : un lien de définition de mot de passe doit donc choisir. C'est le rôle de PasswordLinkRealm (Slab\Framework\Core\Account), résolu depuis le conteneur : il porte un préfixe de noms de routes (front. ou back.) et le compose via route(string $name): string — route('auth.…') donne front.auth.… ou back.auth.….

Le défaut se déduit de la requête en cours : PasswordLinkRealm::detect() rend back() si le chemin contient config('shop.backoffice.prefix'), front() sinon. On s'inscrit sur le front, un membre de l'équipe se crée au back-office — le défaut couvre le cas courant.

WARNING

Mais le back-office crée aussi des comptes clients : le défaut leur enverrait un lien vers l'administration, une porte fermée.

Celui qui sait mieux le dit, en liant sa propre instance le temps de la requête :

php
use Slab\Framework\Core\Account\PasswordLinkRealm;

app()->instance(PasswordLinkRealm::class, PasswordLinkRealm::front());

Le cœur le fait à deux endroits : le hook AccountDefaults dès que le compte enregistré porte le rôle customer (voir ci-dessous), et l'écran des clients quand il valide une inscription (CustomersController::approve()).

La classe est déclarée final : on ne l'étend pas, on lie l'instance qui convient — ::front(), ::back(), ou new PasswordLinkRealm('…') pour un préfixe maison.

Ce que l'enregistrement d'un compte écrit ​

Un compte ne se résume pas à ses colonnes : la langue et la devise de la boutique, le rôle que la surface impose, le statut qui le désactive ou le rend s'écrivent aussi. Fait dans le contrôleur, ce travail ne vaudrait que pour l'écran qui l'a écrit — une IA ou un import n'écriraient pas le même compte. Le cœur le déclare donc une fois en hooks de sauvegarde, que le SavingService exécute pour quiconque enregistre un User (Enregistrer depuis un écran).

php
$hooks->saving(User::class, AccountDefaults::class);
$hooks->saved(User::class, SyncUserRoles::class, priority: 10);
$hooks->saved(User::class, ApplyAccountStatus::class, priority: 20);
Hook (Slab\Framework\Core\Account\Saving)MomentCe qu'il écrit
AccountDefaultsavantlanguage_id et currency_id depuis le référentiel (Language::default(), Currency::default()) ; et il lie PasswordLinkRealm::front() quand les rôles enregistrés contiennent customer
SyncUserRolesaprès, 10les rôles du compte (syncRoles()), tels que l'écran qui l'enregistre les veut
ApplyAccountStatusaprès, 20le statut : AccountStatus::tryFrom(...)?->applyTo($user)

Ces hooks partagent une règle : une valeur absente ne touche à rien. SyncUserRoles sort si la clé roles manque, ApplyAccountStatus si status est vide — une modification qui ne concerne que le téléphone ne réactive pas en silence un compte désactivé, et ne le dépouille pas de son rôle. AccountDefaults distingue, lui, deux cas : la clé présente mais vide (un écran a vidé le champ) et la clé absente sur un compte qui n'a pas de langue retombent toutes deux sur le défaut de la boutique.

Le statut n'est pas une colonne. AccountStatus (Slab\Framework\Models\Enums) donne à l'administrateur son vocabulaire — ACTIVE (active), INACTIVE (inactive) — là où le modèle parle de suppression : applyTo() supprime le compte de façon récupérable ou le restaure, et ne fait rien s'il est déjà dans l'état demandé. AccountStatus::of($user) le relit depuis trashed().

C'est la surface qui décide du rôle, pas celui qui poste

roles n'est pas un champ du formulaire : c'est la Form Request de l'écran qui le pose — SaveCustomerRequest fusionne ['roles' => [UserRole::CUSTOMER->value]] avant validation, parce que le compte qu'enregistre cette fiche est celui d'un client, quoi qu'on lui poste. Une autre surface qui crée des comptes doit poser le rôle du même geste, sinon n'importe quelle demande choisirait ses droits.

TIP

Les trois classes sont final : on ne les étend pas, on déclare son propre hook. À la priorité par défaut (100), il s'exécute après les rôles et le statut du cœur — le compte est donc déjà celui qu'on croit quand il écrit.

Rôles & permissions ​

Les rôles (UserRole : superadmin, team, customer) et les permissions (Permission : access_backoffice, access_mail_previews), tous deux dans Slab\Framework\Models\Enums, sont des enums. Chaque rôle porte ses droits via UserRole::permissions(): ?array — une liste de Permission, ou null pour « tous droits » : superadmin a tous les droits ; team accède au back-office. Vous ajoutez des gates applicatifs via Gate::define.

Ajouter rôles & permissions depuis un package ​

Le cœur expose un AccessControlRegistry : il part de ses enums (repli) et agrège vos contributions. Le seed les matérialise (spatie), et vos permissions se vérifient ensuite par $user->can('…') comme celles du cœur — sans jamais éditer les enums.

php
use Slab\Framework\Core\Account\Contracts\AccessControlRegistry;

public function boot(): void
{
    $registry = $this->app->make(AccessControlRegistry::class);
    $registry->definePermission('export_invoices');   // une permission seule, assignable
    $registry->defineRole('buyer', ['place_quote']);  // un rôle B2B + sa permission
    $registry->grantToRole('team', 'manage_loyalty'); // une permission greffée sur un rôle du cœur
}

Le registre est un singleton (implémentation par défaut : DefaultAccessControlRegistry, déclarée final — on la remplace en reliant l'interface, on ne l'étend pas) : les appels s'accumulent et se dédupliquent. Une permission accordée par grantToRole() (variadique : grantToRole('team', 'manage_loyalty', 'export_invoices')) ou par defineRole(), qui s'y ramène, est de fait connue — definePermission() ne sert qu'aux permissions qu'aucun rôle ne porte. Côté lecture, permissions() rend la liste complète (enums du cœur + contributions) et roles() la table rôle => list<string>|null (rôles du cœur, puis rôles contribués) ; c'est exactement ce que consomme le seeder de rôles pour créer les Permission/Role spatie et synchroniser chaque rôle.

TIP

Le superadmin reste « tous droits » (via un Gate::before) : le registre n'y attache aucune permission — roles() le rend à null et ignore les contributions qui le viseraient (la permission reste connue de permissions(), elle n'est simplement pas rattachée au rôle). Déclarez vos rôles/permissions au boot ; le seed (db:seed) ou votre migration les posent en base.