Retour au cours

backend / php-laravel

Jobs et queues

Leçon 181 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi certaines tâches ne doivent jamais s'exécuter pendant le cycle de requête HTTP
  • Créer un job asynchrone avec ShouldQueue et configurer sa résilience (tries, backoff, timeout)
  • Distinguer le dispatch normal, synchrone et conditionnel d'un job
  • Enchaîner des jobs séquentiellement (Bus::chain) ou les paralléliser (Bus::batch)
  • Faire tourner un worker en production de façon fiable, supervisé et redémarré après déploiement

Dans quel contexte ?

Après validation d'une commande, l'application doit générer un PDF de facture, l'envoyer par email, et notifier le client — trois opérations qui peuvent prendre plusieurs secondes, notamment si le service d'email externe répond lentement. Si tout ça s'exécute pendant la requête HTTP de validation, le client final attendrait plusieurs secondes une réponse qui ne dépend pourtant pas de ces étapes annexes.

D'abord, pourquoi sortir ce travail du cycle de requête ?

Une requête HTTP doit répondre le plus vite possible : l'utilisateur qui valide sa commande attend une confirmation immédiate, pas de patienter le temps qu'un email parte. ShouldQueue transforme un job en tâche différée : au lieu de s'exécuter immédiatement, elle est placée dans une file d'attente et traitée par un processus séparé (un "worker"), indépendant du serveur web.

Cette séparation a un second avantage : si l'envoi d'email échoue temporairement (service externe indisponible), ça n'affecte jamais la réponse déjà envoyée au client, et le job peut être retenté automatiquement.

Prérequis

Cette leçon suppose de connaître les Service Providers et l'injection de dépendances (leçon 7), puisque handle() d'un job peut recevoir des services injectés automatiquement.

Ensuite, un job qui échoue doit savoir comment réagir

tries, backoff et timeout définissent la résilience d'un job face à des échecs transitoires (service externe temporairement indisponible, timeout réseau). backoff() peut même retourner un tableau de délais croissants (10s, 60s, 5min), une stratégie de "backoff exponentiel" qui évite de marteler un service déjà en difficulté.

PropriétéRôle
triesNombre maximal de tentatives avant échec définitif
backoffDélai(s) entre chaque nouvelle tentative
timeoutDurée maximale d'exécution avant interruption forcée
failed()Code exécuté après épuisement de toutes les tentatives

Piège fréquent

Un job sans failed() défini échoue silencieusement après épuisement des tentatives : personne n'est prévenu, et la donnée métier associée (ici, la facturation) reste dans un état incohérent sans que personne ne le sache. Toujours définir failed() au minimum pour logger et marquer l'échec quelque part.

Il reste une différence essentielle entre chaîner et paralléliser

Bus::chain([...]) exécute les jobs strictement dans l'ordre, et s'arrête dès que l'un d'eux échoue — utile quand chaque étape dépend du succès de la précédente. Bus::batch([...]) exécute au contraire les jobs en parallèle, avec des callbacks then/catch déclenchés une fois que TOUT le lot est terminé, peu importe l'ordre réel d'exécution.

Astuce

dispatchSync() exécute un job immédiatement, sans passer par la queue — extrêmement utile en tests automatisés pour vérifier le comportement d'un job sans dépendre d'un worker réellement lancé en arrière-plan.

Enfin, un worker doit tourner en continu et être redémarré au bon moment

php artisan queue:work doit être un processus long-vivant en production, généralement supervisé par un outil comme Supervisor qui le relance automatiquement s'il plante. Un détail facile à oublier : après chaque déploiement, php artisan queue:restart doit être exécuté, sinon les workers déjà lancés continuent de traiter les jobs avec l'ANCIEN code chargé en mémoire.

Maintenant que le travail lourd est déporté hors de la requête HTTP, la prochaine leçon aborde une autre façon de découpler le code : les events et listeners, pour réagir à ce qui se passe dans l'application sans coupler les différentes parties entre elles.

Commandes & code

Jobs et queues

php
<?php
// app/Jobs/EnvoyerFactureJob.php
namespace App\Jobs;

use App\Models\Commande;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class EnvoyerFactureJob implements ShouldQueue {
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;                  // nombre de tentatives avant échec définitif
    public int $backoff = 60;               // délai (s) entre chaque tentative
    public int $timeout = 120;              // durée max d'exécution avant timeout

    public function __construct(private readonly Commande $commande) {}

    public function handle(\App\Services\FacturationService $facturation): void {
        $pdf = $facturation->genererPdf($this->commande);
        $facturation->envoyerParEmail($this->commande->client, $pdf);
    }

    // Appelé si le job échoue définitivement (toutes les tentatives épuisées)
    public function failed(\Throwable $exception): void {
        \Log::error("Échec envoi facture commande {$this->commande->id}: {$exception->getMessage()}");
        $this->commande->update(['statut_facturation' => 'echec']);
    }

    // Détermine dynamiquement quand chaque nouvelle tentative doit avoir lieu (backoff exponentiel)
    public function backoff(): array {
        return [10, 60, 300]; // 10s, puis 60s, puis 5min
    }
}

// Dispatcher un job sur la queue par défaut, ou une queue nommée
EnvoyerFactureJob::dispatch($commande);
EnvoyerFactureJob::dispatch($commande)->onQueue('emails')->delay(now()->addMinutes(5));

// Dispatch conditionnel et synchrone (utile en tests, exécute immédiatement sans queue)
EnvoyerFactureJob::dispatchSync($commande);
EnvoyerFactureJob::dispatchIf($commande->total > 0, $commande);

// Chaînage de jobs : exécution séquentielle, s'arrête si un job échoue
\Illuminate\Support\Facades\Bus::chain([
    new PreparerCommandeJob($commande),
    new EnvoyerFactureJob($commande),
    new NotifierClientJob($commande),
])->dispatch();

// Batch : exécuter plusieurs jobs en parallèle avec callback de complétion
\Illuminate\Support\Facades\Bus::batch([
    new TraiterImageJob($produit1),
    new TraiterImageJob($produit2),
])->then(function ($batch) {
    \Log::info("Traitement terminé: {$batch->processedJobs()} jobs");
})->catch(function ($batch, \Throwable $e) {
    \Log::error("Batch échoué: {$e->getMessage()}");
})->dispatch();
yaml
# config/queue.php -> driver 'redis' recommandé en prod (database/sync en dev/tests seulement)
bash
php artisan queue:work redis --queue=emails,default --tries=3   # worker en premier plan
php artisan queue:work --daemon                                  # process long-vivant (via Supervisor en prod)
php artisan queue:failed                                         # liste les jobs échoués
php artisan queue:retry all                                      # rejoue tous les jobs échoués
php artisan horizon                                               # dashboard de supervision (Redis uniquement)

Résumé

  • ShouldQueue transforme un job en tâche asynchrone traitée par un worker séparé du process HTTP.
  • tries, backoff et timeout contrôlent la résilience face aux échecs transitoires.
  • Bus::chain exécute séquentiellement, Bus::batch exécute en parallèle avec callbacks then/catch.
  • En production, un worker (queue:work) doit tourner en continu via Supervisor, pas juste en dev.

Exercices pratiques

1 disponible
1

Mission : sauver une facturation qui echoue en silence

Objectif : Corriger un job de facturation sans gestion d'echec et un oubli critique dans le processus de deploiement.

Contexte

Le job EnvoyerFactureJob ne definit ni failed(), ni tries, ni backoff. Le service d'email externe a connu une panne de deux heures la semaine derniere : plusieurs centaines de jobs ont echoue definitivement apres leur unique tentative par defaut, sans qu'aucune trace ne soit gardee ni qu'aucune commande ne soit marquee en echec de facturation. Separement, l'equipe vient de deployer une nouvelle version qui change la structure de EnvoyerFactureJob, mais les clients continuent de recevoir des factures generees avec l'ANCIEN format pendant plusieurs minutes apres le deploiement.

Résoudre l’exercice →