backend / php-laravel
Events et listeners
Explication
Ce que vous allez apprendre
- Comprendre comment les events découplent "ce qui se passe" de "ce qu'on en fait"
- Déclencher un event et lui faire porter uniquement les données nécessaires
- Enregistrer plusieurs listeners indépendants pour un même event
- Rendre un listener asynchrone en une seule interface (
ShouldQueue), sans toucher au code du déclencheur - Diffuser un event en temps réel côté client avec
ShouldBroadcast
Dans quel contexte ?
Quand une commande est validée, trois choses distinctes doivent se produire : envoyer un email de confirmation, décrémenter le stock des produits commandés, et notifier en temps réel un tableau de bord administrateur. Sans events, ces trois responsabilités finiraient toutes entassées dans la même méthode valider(), la rendant difficile à faire évoluer sans risquer de casser autre chose.
D'abord, séparer le déclencheur de ses conséquences
Un event (CommandeValidee) est une simple classe qui transporte des données (ici, la commande concernée), sans aucune logique de traitement. CommandeService::valider() se contente de déclencher l'event avec event(new CommandeValidee($commande)), sans jamais savoir ni se soucier de ce qui va réagir à cet événement.
Ce découplage change fondamentalement l'évolutivité du code : ajouter une quatrième réaction à la validation d'une commande ne nécessite de toucher ni au service, ni aux listeners existants — juste d'ajouter un nouveau listener à la liste.
Prérequis
Il faut avoir compris les jobs et les queues (leçon précédente) : un listener peut lui aussi devenir asynchrone via ShouldQueue, exactement comme un job.
Ensuite, plusieurs réactions indépendantes au même déclencheur
EventServiceProvider associe un event à une liste de listeners : EnvoyerConfirmationCommande et DecrementerStock réagissent tous deux à CommandeValidee, sans se connaître mutuellement ni connaître le code qui a déclenché l'event. Si l'un des deux échoue, ça n'affecte pas nécessairement l'autre.
| Élément | Rôle | Sait-il ce qui l'a déclenché ? |
|---|---|---|
| Event | Porte les données du fait survenu | Non, juste les données transmises |
| Listener | Réagit à l'event avec une action précise | Non, ignore les autres listeners |
Dispatcher (event(...)) | Déclenche l'event | Ignore qui va réagir |
Piège fréquent
Empiler trop de logique métier critique directement dans des listeners synchrones peut ralentir l'action initiale sans qu'on s'en rende compte, exactement comme un traitement lourd placé directement dans un contrôleur. Dès qu'un listener effectue un appel réseau ou un traitement lent, ShouldQueue doit être envisagé.
Il reste à rendre certaines réactions asynchrones sans effort
Ajouter implements ShouldQueue à un listener suffit à le faire traiter par un worker en arrière-plan, exactement comme les jobs vus en leçon précédente — sans qu'il faille changer une seule ligne du code qui déclenche l'event. C'est l'un des plus grands avantages du système d'events Laravel : la décision "synchrone ou asynchrone" se prend au niveau du listener, jamais au niveau du déclencheur.
Maintenant, informer le client en temps réel, pas seulement le serveur
ShouldBroadcast diffuse un event via WebSocket (Reverb ou Pusher) vers un canal spécifique, permettant à une interface JavaScript connectée de réagir instantanément sans avoir à interroger le serveur en boucle (polling). broadcastOn() définit précisément quel canal reçoit l'information, et PrivateChannel garantit que seul le client concerné (ici, le client propriétaire de la commande) peut l'écouter.
Astuce
Pour un cas ponctuel qui ne mérite pas une classe listener dédiée, Event::listen(function (CommandeValidee $event) { ... }) accepte directement une closure — pratique pour du prototypage ou une réaction très simple, sans créer un fichier entier.
Maintenant que le code métier est bien découplé et réactif, il devient temps de s'assurer qu'il fonctionne réellement comme prévu : la prochaine leçon couvre les tests automatisés avec PHPUnit et Pest.
Commandes & code
Events et listeners
<?php
// app/Events/CommandeValidee.php : un event porte des données, rien d'autre
namespace App\Events;
use App\Models\Commande;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Broadcasting\PrivateChannel;
class CommandeValidee implements ShouldBroadcast {
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public readonly Commande $commande) {}
// Diffusion temps réel (WebSocket via Reverb/Pusher) sur un canal privé
public function broadcastOn(): array {
return [new PrivateChannel("client.{$this->commande->client_id}")];
}
public function broadcastAs(): string {
return 'commande.validee';
}
}
// app/Listeners/EnvoyerConfirmationCommande.php
namespace App\Listeners;
use App\Events\CommandeValidee;
use Illuminate\Contracts\Queue\ShouldQueue;
class EnvoyerConfirmationCommande implements ShouldQueue {
public function handle(CommandeValidee $event): void {
$event->commande->client->notify(
new \App\Notifications\CommandeConfirmeeNotification($event->commande)
);
}
}
class DecrementerStock {
public function handle(CommandeValidee $event): void {
foreach ($event->commande->produits as $produit) {
$produit->decrement('stock', $produit->pivot->quantite);
}
}
}
// app/Providers/EventServiceProvider.php (ou config auto-discovery Laravel 11+)
namespace App\Providers;
use App\Events\CommandeValidee;
use App\Listeners\{EnvoyerConfirmationCommande, DecrementerStock};
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider {
protected $listen = [
// Plusieurs listeners indépendants réagissent au même event -- découplage fort
CommandeValidee::class => [
EnvoyerConfirmationCommande::class,
DecrementerStock::class,
],
];
}
// Déclenchement de l'event depuis le code métier
namespace App\Services;
use App\Events\CommandeValidee;
class CommandeService {
public function valider(\App\Models\Commande $commande): void {
$commande->update(['statut' => 'validee']);
event(new CommandeValidee($commande)); // ou CommandeValidee::dispatch($commande);
}
}
// Listener anonyme (closure) pour un cas ponctuel, sans classe dédiée
\Illuminate\Support\Facades\Event::listen(function (CommandeValidee $event) {
\Log::info("Commande {$event->commande->id} validée");
});Résumé
- Les events découplent "ce qui se passe" (validation d'une commande) de "ce qu'on en fait" (email, stock, log).
- Plusieurs listeners peuvent réagir au même event indépendamment, sans se connaître entre eux.
ShouldQueuesur un listener le rend asynchrone automatiquement, sans changer le code du dispatcher.ShouldBroadcastdiffuse l'event en temps réel côté client via WebSocket (Reverb/Pusher).
Exercices pratiques
Mission : demeler un stock qui se decremente deux fois
Objectif : Diagnostiquer un listener duplique et concevoir une reaction supplementaire sans toucher au code existant.
Contexte
Le support signale que le stock de certains produits chute plus vite que le nombre de commandes reellement validees. En inspectant EventServiceProvider, tu remarques que DecrementerStock apparait DEUX FOIS dans le tableau $listen[CommandeValidee::class], probablement colle par erreur lors d'un merge Git. Par ailleurs, la direction souhaite ajouter une quatrieme reaction : enregistrer chaque commande validee dans un tableau de bord analytique externe, sans jamais ralentir la reponse HTTP de validation ni risquer de faire echouer la validation si ce service externe est indisponible.