Tous les chemins mènent aux actions sur Laravel

Apprends comment les actions peuvent t'aider à reprendre le contrôle de ta code base.

Tous les chemins mènent aux actions sur Laravel
Photo by Patrick Federi / Unsplash

Laravel est un framework web écrit en PHP qui optimise le délai de mise sur le marché.

Il dispose d'un incroyable éventail d'outils intégrés ou prêts à l'emploi qui offre une grande vélocité pour le déploiement en production des fonctionnalités métier.

Authentification, payement en ligne, monitoring, déploiement serverless, single page application, temps réel, ... Pour ne citer qu'une infime liste d'outil disponibles.

Votre application web devient très vite dense, et l'un des inconvénients est la maintenance qui devient difficile dans l'océan croissant de fonctionnalités.

Un problème de scale

Au fur et à mesure que le temps passe, vous allez modéliser un grand nombre de règles métiers au sein de votre application.

Des Command en console pour automatiser des reporting, des Job pour envoyer des e-mails de rappels, des webhook Controller pour réagir aux événements de vos services tiers.

Votre métier va donc se diluer au fil du temps, et lorsque viendra l'heure de changer une règle métier, les difficultés émergent :

Ai-je bien pensé à modifier la règle dans tous mes contrôleurs ?
Qu'en est-il de cette sous règle qui dépend d'une autre et qui est appelé dans ce Job ?
J'ai l'impression que l'on a deux définitions pour cette même règle métier...

Pour certains c'est évident, le code manquera de factorisation. Pour d'autre, on pourra argumenter que les tests doivent être le garde fou lors d'une réécriture métier.

Le problème c'est qu'il sera très dur d'atteindre une couverture de test complète et fiable. De la même façon, la surcharge de factorisation peut engendrer un couplage trop important du code là où le métier nécessite un niveau de flexibilité supérieur pour des cas d'exception.

Un pattern pour les gouverner tous

Quelle est le point commun entre un Job, une Command, un Controller, et un Listener ?

Ils réagissent tous à un chose : un événement.

  • Un Controller réagit à une requête
  • Un Job réagit au Task Scheduler
  • Une Command réagit à la console
  • Un Listener réagit à un Event

La seule différence sera la sortie ou le résultat.

  • Un Controller renverra une réponse HTTP
  • Un Job effectuera une action de façon asynchrone
  • Une Command renverra un statut en console
  • Un Listener effectuera une action de façon synchrone ou asynchrone

Notez que dans tous les cas, pour que l'action effectuée soit "intéressante" d'un point de vue métier (comprendre qui ajoute de la valeur), elle devra apporter une modification, soit une mutation sur un système pour changer son état.

Pour cela, un pattern intéressant à utiliser est l'Event Sourcing, sujet sur lequel Amin à écrit un article.

Event Sourcing et CQRS, le futur de la gestion de nos données ?
Event Sourcing et CQRS révolutionnent la gestion des données en séparant lecture et écriture, tout en plaçant les événements au cœur des systèmes. Une approche moderne pour des applications performantes, évolutives et résilientes face aux défis actuels.

A ce stade, on vient de voir que Laravel propose 4 voies pour effectuer une modification sur notre système.

Pour résoudre les problèmes de dilution de la connaissance métier dans ces objets Laravel, il nous faut un nouveau point d'entrée unique où nous seront sûr de retrouver toute la connaissance métier modélisée sous forme de code, qui soit à l'epreuve du temps peu importe les mises à jours du framework.

Les Actions ou le début du voyage

On parle souvent de séparation forte entre le framework et le code métier avec le pattern Domain Driven Development (DDD).

Le pattern Action est un premier niveau d'abstraction, qui selon moi se trouve à mi-chemin entre la séparation du domaine, et le fait d'exploiter les outils proposés par le framework.

Imaginons que vous soyez l'auteur d'une application web de gestion de liste de courses.

Après la connexion, l'utilisateur est redirigé vers sa liste de courses, depuis laquelle il peut créer une nouvelle liste ou consulter une liste existante.

Une fois la liste créé, l'utilisateur peut la partager, afin de créer une liste collaborative. Imaginons que l'on se trouve sur la vue d'ajout de collaborateur, et qu'on affiche un formulaire pour en ajouter un :

namespace App\Http\Controllers;

use App\Events\CollaboratorAddedToList;
use App\Http\Requests\ListCollaborator\StoreRequest;
use App\Models\List;
use App\Models\User;
use Illuminate\Http\RedirectResponse;

class ListCollaboratorController extends Controller
{
  public function store(StoreRequest $request, List $list): RedirectResponse
  {
    $user = User::where("slug", $request->string("user"))->findOrFail();
    $list->users()->attach($user);
    
    event(new CollaboratorAddedToList($list, $user));

    return redirect()->route("list.show", $list)->with("success", __("{$user->name} will be notified by email."));
  }
}

Cet exemple à lui seul va mobiliser de la logique métier dans :

  • Une Policy (pour vérifier que l'utilisateur à le droit d'ajouter le collaborateur)
  • Une Rule (pour vérifier si le collaborateur est valide (pas soft deleted, pas banni, ...)
  • Une FormRequest
  • Un Event et un Listener (pour envoyer l'email au collaborateur concerné et les autres collaborateurs de la liste)
  • Une Notification (pour l'envoi du mail)
  • Un Mail (pour le formattage du corps de l'email)
  • Un Accessor (pour l'attribut $user->name qui combine first_name et last_name)
  • Un Controller

C'est donc tout autant de "risque" que notre métier se dilue dans le framework.

Commençons par remplacer la logique du Controller par une Action :

use App\Actions\AddCollaboratorToList;
use App\Actions\FindCollaboratorFromSlug;
use App\Http\Requests\ListCollaborator\StoreRequest;
use App\Models\List;
use Illuminate\Http\RedirectResponse;

class ListCollaboratorController extends Controller
{
  public function store(StoreRequest $request, List $list, FindCollaboratorFromSlug $find, AddCollaboratorToList $add): RedirectResponse
  {
    $user = $find->handle($request->string("user"));
    $add->handle($list, $user);

    return redirect()->route("list.show", $list)->with("success", __("{$user->name} has been notified by email."));
  }
}
💡
J'utilise l'injection de dépendance, mais vous pouvez très bien utiliser app() à l'intérieur de la fonction pour limiter la signature du controlleur.

L'astuce pour savoir comment découper ses actions est la suivante : pas d'élément dépendant du contexte d’exécution.

Une Request est uniquement valable dans le cadre d'une requête HTTP, pas en Console, ni dans un Job, ... C'est pour cela qu'on a deux actions (au lieu d'une seule).

Par contre, un User est un objet qui est universel dans les Controller, Job, Listener, Accessor, Command, ... Donc on peut l'utiliser comme argument.

💡
Les Actions sont de petites unités de valeur : elles doivent faire une seule chose et peuvent appeler d'autres actions si nécessaire.

Pour le nommage, en général l'action commence par un verbe. Pas de règle définitive sur ça seulement une ligne directrice principale (approprie toi ta propre convention, l'idée étant de rester cohérent).

L'Action se différencie du DDD par le fait que les Action sont pensés pour rester "proche" du framework, mais en gardant ce point d'entrée unique qu'est l'Action. Tu conserve toutes les optimisations proposée par Laravel, pas besoin de créer des couches de contrats supplémentaires.

Une action peut en cacher une autre

Voyons ce que l'action qui ajoute un collaborateur dans une liste contient :

namespace App\Actions;

use App\Events\CollaboratorAddedToList;
use App\Models\List;
use App\Models\User;

class AddCollaboratorToList
{
  public function handle(List $list, User $user): void
  {
    $list->users()->attach($user);

    event(new CollaboratorAddedToList($list, $user));
  }
}

L'action peut continuer à interagir avec les objets Laravel, qui eux même vont appeler des Actions pour effectuer le reste de la logique métier : notifier les collaborateurs concernés.

Voyons les Listeners que cet Event déclenche :

namespace App\Providers;

use App\Events\CollaboratorAddedTolist;
use App\Listeners\NotifyNewlyAddedCollaborator;
use App\Listeners\NotifyOtherCollaborators;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider
{
  protected $listen = [
    CollaboratorAddedToList::class => [
      NotifyNewlyAddedCollaborator::class,
      NotifyOtherCollaborators::class
    ],
  ];

  // ...
}

Et voyons maintenant le deuxième Listener qui contient une logique intéressante :

namespace App\Listeners;

use App\Events\CollaboratorAddedToList;
use App\Actions\NotifyOtherCollaboratorsOfNewCollaborator;

class NotifyOtherCollaborators
{
  public function handle(CollaboratorAddedToList $event, NotifyOtherCollaboratorsOfNewCollaborator $action): void
  {
    $action->handle($event->list, $event->user);
  }
}

Je pense que vous commencez à comprendre le mécanisme : les objets Laravel sont "vides" et ne font qu'appeler une Action. On termine avec le contenu de l'action :

namespace App\Actions;

use App\Notifications\NotifyOtherCollaboratorsOfNewCollaborator as Notification;
use App\Models\List;
use App\Models\User;

class NotifyOtherCollaboratorsOfNewCollaborator
{
  public function handle(List $list, User $user): void
  {
    $list
      ->collaborators
      ->reject(fn (User $user): bool => $user->is($event->user))
      ->each(fn (User $otherUser): void => $otherUser->notify(new Notification($list, $user)));
  }
}

Pour que ce pattern puisse être efficace sur le long terme (maintenance facilitée en cas de changement du métier), il faut aller faire l'extra miles, y compris dans les "petits" objets Laravel comme les Accessors :

namespace App\Models;

use App\Actions\GetUserName;
use Illuminate\Database\Eloquent\Casts\Attribute;

class User
{
  protected $fillable = [
    "first_name",
    "last_name",
  ];

  protected function name(): Attribute
  {
    return Attribute::make(
      get: fn (): string => app(GetUserName::class)->handle($this->first_name, $this->last_name),
    );
  }
}

A première vue cela semblera "overkill", mais c'est nécessaire pour garantir que toute la logique (de la plus insignifiante comme celle-ci à la plus compliquée) se trouve au même endroit.

Voilà une vision d'ensemble de ce que l'on à construit :

app
  Actions
    AddCollaboratorToList.php
    FindCollaboratorFromSlug.php
    GetUserName.php
  Events
    CollaboratorAddedToList.php
  Http
    Controllers
      ListCollaboratorController.php
    Requests
      ListCollaborator
        StoreRequest.php
  Listeners
    NotifyNewlyAddedCollaborator.php
    NotifyOtherCollaborators.php
  Models
    List.php
    User.php
  Notifications
    NotifyOtherCollaboratorsOfNewCollaborator.php

Prochain arrêt : le pattern Modules

Dans la vue d'ensemble ci-dessus, on voit qu'on identifie clairement toutes les logiques métiers dans un seul dossier. C'est un bon début !

Mais si on pense à la prochaine étape du scale, ce dossier va comprendre énormément de classes à mesure que le produit évolue.

Une première étapes est de découper en grande famille les Actions, les Controllers, Listeners, ... pour avoir un sous modules qui correspond au groupement logique dans chacun de ces dossiers.

La seconde étape, c'est de passer au pattern Module : au lieu que le pivot soit fait au niveau des objets Laravel (dossiers par Controllers, Jobs, ...), il est effectué au niveau métier.

Voici un aperçu du module Pattern appliqué à notre arborescence du dessus :

app
└── Modules
    ├── User
    │   ├── Actions
    │   │   └── GetUserName.php
    │   └── Models
    │       └── User.php
    ├── List
    │   └── Models
    │       └── List.php
    └── ListCollaborator
        ├── Actions
        │   ├── AddCollaboratorToList.php
        │   └── FindCollaboratorFromSlug.php
        ├── Http
        │   ├── Controllers
        │   │   └── ListCollaboratorController.php
        │   └── Requests
        │       └── ListCollaborator
        │           └── StoreRequest.php
        ├── Events
        │   └── CollaboratorAddedToList.php
        ├── Listeners
        │   ├── NotifyNewlyAddedCollaborator.php
        │   └── NotifyOtherCollaborators.php
        └── Notifications
            └── NotifyOtherCollaboratorsOfNewCollaborator.php      

A première vue, cela rajoute de la complexité. Mais sur le long terme on y gagne :

L'onboarding des nouveaux développeurs est facilité, car l'approche produit est plus évidente lorsqu'on commence par la liste des notions clés (Liste, Collaborateurs, Utilisateurs)

On peut facilement voir les points sensibles (ici le plus gros de cette application concerne le lien entre listes et collaborateurs)

J'aborderait plus en détail les avantages de ce pattern dans un prochain article (lien prochainement disponible).

Et le pattern Service alors ?

Dans l'ensemble, Service = Action.

James Franco qui fait une tête étonnée sûrement en entendant une énorme bêtises.
Ta réaction quand tu comprends que tu t'es fait berné

On peut considérer qu'ils sont équivalents, car dans l'idée les Services sont censés servir de "helpers" pour combiner plusieurs actions en une seule plus simple.

Les services contiennent donc également de la logique métier. Sauf que selon moi :

  • Ils sont pensés/prévus pour mélanger logique métier et framework, de façon à être utilisé comme de véritable joker pour factoriser un code important
  • Ils sont donc encore plus couplés (par définition) que les Actions (oui c'est discutable, et oui je t’entends toi derrière ton écran)
  • Ils doivent être utilisé en pair avec d'autre pattern comme le Repository pour éviter que le Service ne s'occupe de la partie requête avec Eloquent et devenir trop dense, ce qui crée une friction supplémentaire

Selon moi une autre raison qui fait que ce pattern n'a pas vu énormément de succès auprès de la communauté Laravel c'est son aspect peu "Fluent".

Les services sont plus susceptibles d'être "fourre-tout" car par design on appelle une méthode d'un service. Donc il est tentant de se retrouver avec une classe qui brise les règles de séparation de responsabilité, avec des méthodes beaucoup trop importantes ce qui fait que l'on déplace seulement le problème vers les services.

Là où les Actions sont plus élégantes c'est dans leur nature unitaire : une action représente une seul méthode si on la comparait à son homologue dans un Service.

Comme une action peut en appeler une autre, les Actions favorisent les bonnes pratiques de séparation de la responsabilité.

Conclusion

Le pattern Action offre plusieurs avantages clés pour votre application Laravel :

  • Une bonne séparation entre la logique métier et le framework
  • Un point d'entrée unique pour chaque règle métier, facilitant la maintenance
  • Une meilleure testabilité grâce à des unités de code isolées et focalisées
  • Chaque action est "mockable", ce qui simplifie d'autant plus les tests
  • Une architecture évolutive qui s'adapte naturellement vers le pattern Module

L'adoption de ce pattern demande un certain investissement initial, notamment dans la réorganisation du code et la formation de l'équipe. Cependant, cet investissement devient rapidement rentable dès que l'application commence à grandir.

Quelques conseils pour démarrer :

  1. Commence par identifier les règles métier les plus critiques de ton application
  2. Crée des Actions pour ces règles en priorité
  3. Établis des conventions de nommage claires avec ton équipe
  4. N'essaie pas de couvrir l'existant en premier : concentre-toi sur les nouvelles actions
  5. Si le changement de code n'est pas important et qu'il est couvert par des tests fiables, profite-en pour effectuer un passage en Action lors d'un correctif ou d'une modification de code

N'hésite pas à créer le débat au sein de ton entreprise ou ton équipe : l'échange est sain. L'important est de trouver un équilibre entre la rigueur architecturale et la praticité du framework Laravel.

Un dernier conseil : si tu souhaites intégrer ce pattern dans une code base existante, assure-toi que tes collaborateurs comprennent les avantages concrets que ce pattern peut apporter à votre projet spécifique.