Nous developpons votre site vitrine SPA À PARTIR DE 300€. Oui, c'est dingue. Web SPA À PARTIR DE 300€ — Oui, c'est dingue. Parlons-en →

Où placer la logique métier si vous n'en voulez ni dans le contrôleur ni dans le modèle

Où placer la logique métier si vous n'en voulez ni dans le contrôleur ni dans le modèle

Presque aucun projet ne décide consciemment où vit sa logique métier. Elle s'accumule là où c'est commode sur le moment et, le jour où vous vous en apercevez, la réponse à « où se fait ceci ? » dépend de qui l'a écrit. Parcourons les trois options habituelles, voyons ce qui casse exactement dans chacune et ce que gagne une opération métier quand elle dispose de sa propre classe, y compris ce que cela coûte. Chez AndorraDev nous travaillons surtout avec Laravel, donc les exemples s'appuient sur son écosystème, mais le débat est identique avec Symfony, Rails ou Django et le critère se transpose entièrement.

Le contrôleur n'est pas le bon endroit, le modèle non plus

Toute application arrive au même point. Vous commencez par écrire la logique dans le contrôleur parce que c'est là qu'entre la requête, et cela fonctionne quelques mois. Ensuite ce contrôleur a douze méthodes, chacune fait cinq choses et personne ne sait laquelle s'exécute aussi depuis ailleurs.

La réaction habituelle est de tout déplacer dans le modèle. Et là le problème change de forme mais pas de taille : le modèle se met à connaître les e-mails, la facturation, les PDF et les files d'attente, alors que son travail était de représenter une ligne d'une table.

La troisième voie, les classes de service, est meilleure. Mais elle a un trou qui se remarque au bout de deux ans.

Ce qui cloche dans les trois réponses habituelles

Dans le contrôleur, la logique reste liée à une requête HTTP. Le jour où il faut exécuter la même chose depuis une commande planifiée, depuis une file ou depuis un autre point de l'application, vous copiez-collez. À partir de là il y a deux versions de la même règle métier et une seule est corrigée quand elle change.

Dans le modèle, n'importe quelle méthode peut toucher n'importe quoi, donc le fichier grossit sans plafond. Et comme c'est un objet instancié partout, il est impossible de savoir d'où l'on appelle quoi.

Dans une classe de service le code reste raisonnablement rangé, mais il n'y a pas de convention d'invocation. Chaque service décide s'il s'instancie, s'injecte ou est statique, et c'est celui qui l'a écrit ce jour-là qui tranche. Et il y a un second coût : quand cette même opération doit s'exécuter en arrière-plan, vous écrivez un Job à part qui répète la signature et relaie l'appel. Deux classes pour une seule opération.

Une opération métier est une classe avec une méthode

L'idée du motif d'actions est simple : une opération, une classe, une méthode. On l'appelle pareil d'où que ce soit, et le nom de la classe dit ce qu'elle fait.

Voici une vraie, tirée d'une plateforme juridique que nous avons construite :

class GenerateQrCodeAction extends Action
{
    protected CaseModel $caseModel;

    public function handle(int $size = 150): string
    {
        $url = route('cases.show', $this->caseModel->slug);

        return (string) QrCode::size($size)
            ->style('round')
            ->margin(1)
            ->generate($url);
    }
}

C'est tout. Elle n'hérite pas d'un contrôleur, ne vit pas dans le modèle et n'exige de personne qu'il se souvienne comment on l'instancie.

La même classe s'exécute maintenant ou en arrière-plan

Voici la différence qui compte vraiment face à une classe de service. La même action s'exécute des deux façons sans écrire de Job séparé :

$this->file->action('resize_image')->run();
$this->file->action('auto_tag')->queue('default')->dispatch();

Ce fragment est réel, tiré d'Abodara, et il est dans la même méthode. Le redimensionnement se fait tout de suite parce que l'utilisateur attend de voir l'image. L'étiquetage automatique, qui appelle un modèle de vision et prend du temps, part en file. Deux comportements, une seule classe, aucune duplication.

Les tentatives et le délai se déclarent dans l'action elle-même, pas au point d'appel :

class AutoTagAction extends Action
{
    public File $file;

    protected int $tries = 5;
    protected ?int $delay = 30;

Déclarer les actions dans le modèle qui les utilise

Une fois que les opérations vivent dans des classes séparées, un problème nouveau apparaît : les retrouver. Rien dans le modèle ne dit ce qu'on peut en faire, et cette information finit éparpillée entre contrôleurs et vues. La déclarer dans le modèle lui-même la ramène à un seul endroit :

protected array $actions = [
    'generate_qr'       => GenerateQrCodeAction::class,
    'create_slot'       => CreateCaseSlotAction::class,
    'add_fee'           => AddCaseFeeAction::class,
    'create_proforma'   => CreateProformaAction::class,
    'send_email'        => SendCaseEmailAction::class,
];

Et ensuite, $case->action('send_email')->run([...]). Le modèle ne contient pas la logique, mais il publie le catalogue de ce qu'on peut lui faire. Ouvrir ce fichier et lire la liste est la documentation la plus utile que vous aurez de votre domaine.

Dans Abodara il y a un modèle File qui déclare seize opérations, de la génération de variantes à l'application d'un filigrane. Aucune ne vit dans le modèle.

Andie recommande

Déclarez toujours les valeurs par défaut dans la signature de handle() et validez à l'intérieur. L'action documente ainsi son propre contrat : qui la lit sait ce qu'elle attend et ce qui se passe si quelque chose n'arrive pas, sans avoir à chercher qui l'appelle. C'est ce qui permet à la même action de servir depuis un contrôleur, depuis une file ou depuis l'outil d'un agent IA.

Ce que l'on gagne quand deux endroits différents appellent la même chose

Le cas qui justifie tout le motif est apparu en montant le copilote IA d'une plateforme juridique. Il y a une opération, rédiger une clause à partir d'une description, invoquée depuis deux endroits qui n'ont rien à voir : un composant de l'interface et un outil de l'agent IA.

Sans actions, ce sont deux implémentations. Avec une action, ce sont deux lignes presque identiques appelant la même classe :

// Depuis le composant de l'interface
$result = DraftClauseAction::create()->actor(auth()->user())->run([...]);

// Depuis l'outil de l'agent
$result = DraftClauseAction::create()->actor($user)->run([...]);

La validation, l'appel au modèle de langage et l'enregistrement du brouillon vivent au même endroit. Quand la règle changera, elle changera une fois.

Cet actor() n'est pas décoratif non plus : il enregistre qui exécute l'opération, ce qui, dans un produit avec des équipes et des permissions, fait la différence entre pouvoir auditer et ne pas pouvoir. Sur cette plateforme il est utilisé dans 41 appels.

Ce qui existe sur le marché et en quoi cela diffère

L'alternative connue sur Laravel est lorisleiva/laravel-actions, et c'est un paquet solide. La différence technique est réelle et mérite d'être comprise, car ils ne visent pas la même chose.

Ce paquet transforme une action en plusieurs choses à la fois : elle peut s'enregistrer comme contrôleur sur une route, servir de listener d'événements et de commande artisan. C'est plus de surface, et pour qui veut cette unification, c'est bien résolu.

Laractions, que nous maintenons et utilisons, en fait délibérément moins. Il se concentre sur une seule question, où vit une opération métier, et apporte en échange deux choses que l'autre n'a pas : le registre déclaratif dans le modèle vu plus haut, et la notion explicite d'acteur, avec un audit optionnel en base.

Si vous voulez que vos actions soient aussi vos routes et vos commandes, le choix est l'autre. Si vous voulez sortir la logique métier du contrôleur et du modèle en laissant un catalogue lisible par modèle, et pouvoir l'exécuter en direct ou en file sans rien écrire de plus, celui-ci convient mieux. C'est la raison pour laquelle nous l'avons écrit : c'était le problème que nous avions.

Trois détails à connaître avant

Aucun patron n'est gratuit, et celui-ci se paie à des endroits précis qu'il vaut mieux connaître avant de réécrire la moitié d'un projet avec. Aucun n'est rédhibitoire, mais les trois se présenteront.

L'héritage consomme votre unique héritage. Une action fait extends Action, donc cette classe ne peut plus en étendre une autre. En pratique une action n'hérite en général de rien d'autre, mais autant le savoir.

La mise en file est délibérément simple. Elle retourne void, donc pas de chaînes, de lots ni d'afterCommit. Pour des orchestrations complexes de tâches d'arrière-plan, vous aurez toujours besoin des Jobs de Laravel ; ceci couvre le cas « ça prend du temps, fais-le plus tard », soit 90% des situations.

La validation intégrée couvre les paramètres de la méthode. Ce qui arrive comme propriété de l'action, vous le validez là où cela vous arrange. Il vaut la peine de trancher dès le départ et de s'y tenir, car déplacer une donnée d'un endroit à l'autre change qui la vérifie.

Et si vous ne travaillez pas avec Laravel

Le motif n'appartient ni à Laravel ni à PHP. Sur Symfony, l'équivalent naturel sont les message handlers de Messenger, qui séparent déjà l'opération de celui qui l'invoque et donnent le même synchrone ou asynchrone. Sur Node, un module par cas d'usage avec une seule fonction exportée obtient la même chose sans aucune bibliothèque.

Ce qui compte, c'est le critère : une opération métier doit pouvoir s'invoquer pareil depuis une requête, depuis une file et depuis un test, et son nom doit dire ce qu'elle fait. Si votre code respecte cela, peu importe comment vous l'appelez.

Où est le code

Le paquet est publié sur Packagist, le code sur GitHub et la documentation du paquet couvre toutes les options. Il est gratuit, et entre les deux plateformes citées il totalise 81 actions en production.

Si vous avez une application qui a grossi et que vous ne savez plus où mettre chaque chose, c'est exactement le problème que nous traitons en applications web sur mesure et développement Laravel. Racontez-nous et nous y jetons un œil.

Écrit par
Edu Lazaro
Edu Lazaro
Founder & Lead Developer en AndorraDev

Développeur full-stack avec plus de 15 ans d'expérience en Laravel, React, Node.js et architectures cloud. J'aide les entreprises en Andorre à construire leur présence digitale.

Partner de diseño · ionospace.
Besoin d'aide? ×
Andie by AndorraDev
Assistant IA + équipe humaine
Assistant IA d'AndorraDev
Andie
Bonjour! Je suis Andie, l'assistant IA d'AndorraDev. Comment puis-je vous aider? Si vous souhaitez parler avec Edu, il suffit de le demander.
17:29