backend / php-laravel
Jobs et queues
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
ShouldQueueet 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 |
|---|---|
tries | Nombre maximal de tentatives avant échec définitif |
backoff | Délai(s) entre chaque nouvelle tentative |
timeout | Duré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
// 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();# config/queue.php -> driver 'redis' recommandé en prod (database/sync en dev/tests seulement)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é
ShouldQueuetransforme un job en tâche asynchrone traitée par un worker séparé du process HTTP.tries,backoffettimeoutcontrôlent la résilience face aux échecs transitoires.Bus::chainexécute séquentiellement,Bus::batchexécute en parallèle avec callbacksthen/catch.- En production, un worker (
queue:work) doit tourner en continu via Supervisor, pas juste en dev.
Exercices pratiques
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.