← Tous les articles

LARAVEL

J'utilise de moins en moins les observers dans Laravel

Les observers rendent le code plus court à écrire, mais plus dur à relire. Pourquoi je préfère maintenant des appels explicites, et les quelques cas où je les garde.

Publié le · mis à jour le

Pendant longtemps, j'ai mis des observers un peu partout dans mes projets Laravel. Un utilisateur s'inscrit, on lui envoie un mail de bienvenue. Une commande passe en payée, on prévient la logistique. Un article est publié, on vide le cache. Tout ça rangé dans des classes XxxObserver, bien propres, branchées sur les événements du modèle.

Sur le moment, ça a l'air élégant. Le contrôleur reste court, le modèle aussi, et la logique "annexe" est rangée ailleurs.

Le problème, c'est le jour où quelqu'un d'autre ouvre le code. Ou moi, six mois plus tard.

Le code ne dit plus ce qu'il fait

Prenons un formulaire de contact. Version observer :

// app/Http/Controllers/ContactController.php
public function store(ContactRequest $request): RedirectResponse
{
    ContactMessage::create($request->validated());

    return back()->with('sent', true);
}
// app/Observers/ContactMessageObserver.php
class ContactMessageObserver
{
    public function created(ContactMessage $message): void
    {
        Notification::route('mail', config('services.contact.to'))
            ->notify(new ContactMessageReceived($message));
    }
}

En lisant le contrôleur, rien n'indique qu'un mail part. On voit un create(), une redirection, et c'est tout. Pour savoir ce qui se passe vraiment, il faut déjà savoir qu'un observer existe, retrouver où il est branché (dans un service provider, ou avec l'attribut #[ObservedBy] sur le modèle), puis lire ses méthodes une par une. Trois fichiers pour comprendre une action qui tient en dix lignes.

Et ce n'est pas qu'une question de confort. Le jour où le mail ne part pas, ou part deux fois, la première chose à faire c'est retrouver qui l'envoie. Avec un observer, la réponse n'est pas dans le fichier que tu as sous les yeux.

Les pièges qu'on découvre en prod

Les événements Eloquent ne se déclenchent pas à chaque fois qu'une ligne change en base. Un update() lancé directement sur une requête ne passe jamais par le modèle :

// Déclenche updating et updated
$message->update(['handled_at' => now()]);

// Ne déclenche rien du tout
ContactMessage::query()
    ->whereNull('handled_at')
    ->update(['handled_at' => now()]);

Pareil pour insert(), upsert(), ou un delete() sur une requête. Du coup, ta règle métier s'applique, sauf quand quelqu'un écrit la requête autrement. Et rien ne te prévient.

Il y a aussi le problème inverse. On finit par semer des saveQuietly() et des Model::withoutEvents() dans le code, pour qu'un observer ne se déclenche pas au mauvais moment : pendant un import, une commande de maintenance, un seeder. À ce stade, on passe plus de temps à contourner la logique qu'à l'écrire.

Les tests trinquent aussi. Une factory qui crée un modèle observé déclenche l'observer. Un test qui n'a rien à voir avec les notifications se met à en envoyer, et on finit par coller des Notification::fake() partout, juste pour être tranquille.

Ce que je fais maintenant

J'écris l'appel là où l'action a lieu :

public function store(ContactRequest $request, ContactMessageNotifier $notifier): RedirectResponse
{
    $message = ContactMessage::create($request->validated());

    // Après la réponse : le visiteur n'attend pas le serveur mail.
    $notifier->sendAfterResponse($message);

    return back()->with('sent', true);
}

C'est une ligne de plus dans le contrôleur. En échange, n'importe qui qui ouvre ce fichier voit qu'un message enregistré déclenche une notification, et sait où aller pour voir comment elle part. Le jour où je veux enregistrer un message sans prévenir personne, dans un import par exemple, je n'appelle pas le notifier, point. Pas de saveQuietly(), pas d'exception à garder en tête.

Quand l'action grossit, je la sors dans une classe à elle, avec un nom qui dit ce qu'elle fait :

final class PublishPost
{
    public function __construct(private SitemapWriter $sitemap) {}

    public function __invoke(Post $post): void
    {
        $post->update(['published_at' => now()]);

        Cache::forget('blog.latest');
        $this->sitemap->write();
    }
}

Les effets sont listés dans l'ordre où ils arrivent. Pour tester, j'appelle PublishPost et je vérifie ce qui doit se passer, sans me demander quel observer va se réveiller au passage.

Là où je les garde quand même

Je ne les ai pas virés de partout. Je garde un hook de modèle quand il ne sert qu'à garder le modèle cohérent avec lui-même : générer un identifiant à la création, remplir une date quand un statut change, normaliser une valeur avant l'enregistrement. Ce sont des règles qui doivent s'appliquer peu importe comment le modèle est sauvé, et qui ne sortent pas de la ligne en base.

protected static function booted(): void
{
    static::creating(function (Post $post): void {
        $post->uid ??= (string) Str::ulid();
    });
}

Et dans ce cas, je le mets dans le booted() du modèle plutôt que dans une classe à part. Comme ça, on le voit en ouvrant le modèle, sans avoir à chercher.

Ma règle, en gros : si l'effet sort du modèle (un mail, un appel d'API, une autre table, une file d'attente), il doit être visible là où l'action est déclenchée. S'il reste à l'intérieur du modèle, un hook peut faire l'affaire.

Les observers ne sont pas une mauvaise fonctionnalité. Ils rendent juste le code plus court au moment où on l'écrit, et plus long à comprendre au moment où on le relit. Et en vrai, on passe bien plus de temps à relire du code qu'à en écrire. Une ligne de plus dans un contrôleur, je prends.