Cues i Jobs

Com processar tasques en segon pla amb cues a Laravel: jobs, workers, reintentos, jobs encadenats i Horizon.

Per què necessites cues?#

Quan un usuari fa una acció a la teva aplicació, espera una resposta ràpida. Si clica "Registra'm" i la petició triga 8 segons perquè el servidor està enviant un correu de benvinguda, generant un PDF amb les condicions d'ús i redimensionant l'avatar pujat, l'experiència és pèssima. L'usuari no sap si ha funcionat, potser clica dues vegades, potser tanca la pestanya. El problema no és que aquestes tasques siguin necessàries: el problema és que no cal que es facin en aquell instant, bloquejant la resposta HTTP.

Les cues resolen exactament això. En lloc d'executar una operació costosa dins de la petició HTTP, l'encues perquè es processi en segon pla. La resposta arriba a l'usuari en mil·lisegons i un procés separat (el worker) s'encarrega d'executar la tasca quan li toca. L'usuari veu "Registre completat!" immediatament, i el correu de benvinguda arriba uns segons després sense que ningú hagi notat el retard.

Les cues són especialment útils per a operacions que impliquen serveis externs (enviar correus, cridar APIs de tercers, processar pagaments), operacions intensives en CPU (generació d'imatges, conversió de vídeo, processament de fitxers) i operacions que poden fallar i necessiten reintentos automàtics. Laravel ofereix un sistema de cues robust que suporta múltiples backends, reintentos, prioritats, encadenament de jobs i monitorització.

Configuració#

La configuració de les cues es gestiona principalment a dos llocs: el fitxer .env per definir el driver actiu i el fitxer config/queue.php per configurar cada driver en detall. El driver determina on s'emmagatzemen els jobs pendents mentre esperen ser processats.

El driver per defecte és sync, que executa els jobs immediatament dins de la petició HTTP, sense cap cua real. Això és útil durant el desenvolupament perquè veus els errors immediatament, però no és el que vols en producció. Per a producció, necessites un driver que realment encuï els jobs per ser processats en segon pla:

# Driver de la cua (sync, database, redis, sqs, beanstalkd)
QUEUE_CONNECTION=redis
 
# Si fas servir database com a driver
# QUEUE_CONNECTION=database
 
# Si fas servir Amazon SQS
# AWS_ACCESS_KEY_ID=la-teva-clau
# AWS_SECRET_ACCESS_KEY=el-teu-secret
# SQS_PREFIX=https://sqs.eu-west-1.amazonaws.com/el-teu-account
# SQS_QUEUE=la-teva-cua

Drivers disponibles#

Cada driver té característiques diferents que el fan adequat per a situacions concretes. L'elecció del driver és una decisió important perquè afecta el rendiment, la fiabilitat i la complexitat de la infraestructura.

El driver sync executa els jobs en línia, sense cua. És el driver per defecte i l'ideal per a desenvolupament local perquè els errors apareixen immediatament a la resposta HTTP. Mai s'ha d'utilitzar en producció perquè derrota tot el propòsit de les cues.

El driver database utilitza una taula de la base de dades per emmagatzemar els jobs pendents. És la opció més senzilla per començar a producció perquè no requereix cap infraestructura addicional. Només cal crear la taula de migració:

php artisan queue:table
php artisan migrate

El driver redis és l'opció recomanada per a la majoria d'aplicacions en producció. Redis és extremadament ràpid, suporta operacions atòmiques i permet funcionalitats avançades com la priorització de cues i el monitoratge amb Horizon. Si ja fas servir Redis per a la cache (que és habitual), afegir-lo com a driver de cues no suposa cap infraestructura addicional.

El driver sqs (Amazon Simple Queue Service) és ideal per a aplicacions desplegades a AWS o que necessiten escalar automàticament. SQS és un servei gestionat, cosa que significa que no has de preocupar-te per la infraestructura de la cua.

// config/queue.php
'connections' => [
    'sync' => [
        'driver' => 'sync',
    ],
 
    'database' => [
        'driver' => 'database',
        'connection' => env('DB_QUEUE_CONNECTION'),
        'table' => env('DB_QUEUE_TABLE', 'jobs'),
        'queue' => env('DB_QUEUE', 'default'),
        'retry_after' => env('DB_QUEUE_RETRY_AFTER', 90),
        'after_commit' => false,
    ],
 
    'redis' => [
        'driver' => 'redis',
        'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
        'queue' => env('REDIS_QUEUE', 'default'),
        'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90),
        'block_for' => null,
        'after_commit' => false,
    ],
 
    'sqs' => [
        'driver' => 'sqs',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
        'queue' => env('SQS_QUEUE', 'default'),
        'suffix' => env('SQS_SUFFIX'),
        'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
        'after_commit' => false,
    ],
],

El paràmetre retry_after és important: defineix quants segons han de passar abans que un job "aturat" es consideri fallit i es torni a intentar. Si un worker mor mentre processa un job (per exemple, per un kill del sistema operatiu), el job quedarà bloquejat fins que passi el temps de retry_after. Assegura't que aquest valor sigui superior al temps màxim d'execució dels teus jobs.

Crear un job#

Un job és una classe PHP que encapsula una tasca que es pot executar en segon pla. Cada job és autònom: conté tota la informació necessària per executar-se i defineix com s'ha de comportar si falla. Aquesta encapsulació és clau perquè el job pot ser serialitzat, emmagatzemat en una cua i deserialitzat hores després per un worker completament diferent.

Per crear un job, utilitza l'Artisan:

php artisan make:job ProcessPodcast

Això crea una classe a app/Jobs/ProcessPodcast.php. La interfície ShouldQueue indica a Laravel que aquest job s'ha d'encuar en lloc d'executar-se immediatament. Sense aquesta interfície, el job s'executaria de manera síncrona dins de la petició HTTP, independentment del driver de cua configurat:

namespace App\Jobs;
 
use App\Models\Podcast;
use App\Services\AudioProcessor;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
 
class ProcessPodcast implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
    public function __construct(
        public Podcast $podcast
    ) {}
 
    public function handle(AudioProcessor $processor): void
    {
        $processor->process($this->podcast);
    }
}

Cada trait aporta una funcionalitat específica. Dispatchable permet enviar el job a la cua amb ProcessPodcast::dispatch(). InteractsWithQueue dona accés a mètodes per interactuar amb la cua des de dins del job, com delete() o release(). Queueable permet especificar a quina cua o connexió s'envia el job. SerializesModels serialitza i deserialitza automàticament els models Eloquent passats al constructor: en lloc de serialitzar tot l'objecte, només emmagatzema l'ID i el torna a carregar de la base de dades quan el worker processa el job.

Estructura del mètode handle#

El mètode handle és on definim la lògica del job. Laravel resol automàticament les dependències injectades al mètode gràcies al contenidor de serveis. Això significa que pots tipar qualsevol servei al handle i Laravel l'instanciarà automàticament:

class GenerateInvoice implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
    public function __construct(
        public Order $order
    ) {}
 
    public function handle(PdfGenerator $pdf, Mailer $mailer): void
    {
        // Generar el PDF de la factura
        $path = $pdf->generate('invoices.template', [
            'order' => $this->order,
            'customer' => $this->order->customer,
            'items' => $this->order->items,
        ]);
 
        // Emmagatzemar-lo
        $this->order->update(['invoice_path' => $path]);
 
        // Enviar per correu al client
        $mailer->to($this->order->customer->email)
            ->send(new InvoiceMail($this->order, $path));
    }
}

Fixa't en la diferència entre el constructor i el handle. El constructor rep les dades específiques del job (el model Order que s'ha de processar). Aquestes dades es serialitzen quan el job s'encua i es deserialitzen quan el worker el processa. El handle, en canvi, rep dependències del contenidor de serveis (PdfGenerator, Mailer), que es resolen en el moment de l'execució. Aquesta separació és important: les dades del constructor han de ser serialitzables (models, strings, enters, arrays), mentre que les dependències del handle poden ser qualsevol servei registrat al contenidor.

Dispatching de jobs#

Enviar un job a la cua és senzill, però Laravel ofereix diverses maneres de controlar com i quan s'encua. El mètode més directe és dispatch, que envia el job immediatament a la cua configurada:

use App\Jobs\ProcessPodcast;
 
// Dispatch bàsic
ProcessPodcast::dispatch($podcast);
 
// Dispatch condicional: només si es compleix una condició
ProcessPodcast::dispatchIf($podcast->isReady(), $podcast);
 
// Dispatch invers: tret que es compleixi una condició
ProcessPodcast::dispatchUnless($podcast->isProcessed(), $podcast);

Retardar l'execució#

De vegades no vols que el job s'executi immediatament, sinó després d'un temps determinat. Per exemple, enviar un correu de recordatori 24 hores després del registre, o processar un pagament 30 minuts després de la comanda per donar temps a cancel·lar. El mètode delay permet programar l'execució:

// Executar d'aquí 10 minuts
ProcessPodcast::dispatch($podcast)
    ->delay(now()->addMinutes(10));
 
// Executar demà a les 9 del matí
ProcessPodcast::dispatch($podcast)
    ->delay(now()->tomorrow()->setHour(9));
 
// Executar d'aquí 1 hora
ProcessPodcast::dispatch($podcast)
    ->delay(60 * 60); // en segons

Especificar cua i connexió#

Si l'aplicació gestiona múltiples cues (per exemple, una per correus i una altra per processament d'imatges), pots especificar a quina cua s'envia cada job. Això permet assignar workers amb diferents prioritats o recursos a cada cua:

// Enviar a una cua específica
ProcessPodcast::dispatch($podcast)
    ->onQueue('processing');
 
// Enviar a una connexió específica
ProcessPodcast::dispatch($podcast)
    ->onConnection('sqs');
 
// Combinar connexió i cua
ProcessPodcast::dispatch($podcast)
    ->onConnection('redis')
    ->onQueue('high-priority');

Dispatch després de la resposta#

Hi ha casos on una tasca és prou ràpida per no necessitar una cua completa, però no vols que retardi la resposta HTTP. El mètode afterResponse executa el job un cop la resposta HTTP s'ha enviat al client, però dins del mateix procés (sense worker). És ideal per a tasques lleugeres com registrar una activitat o actualitzar un comptador:

// S'executarà després d'enviar la resposta HTTP
SendWelcomeEmail::dispatchAfterResponse($user);

Dispatch sincrón#

Si necessites forçar l'execució immediata d'un job (per exemple, en tests o en una tasca Artisan), pots utilitzar dispatchSync:

// Executar immediatament, sense encuar
ProcessPodcast::dispatchSync($podcast);

Middleware de jobs#

De la mateixa manera que les peticions HTTP passen per middleware abans d'arribar al controlador, els jobs poden tenir middleware que s'executa abans del mètode handle. Això permet encapsular lògica comuna com rate limiting, prevenció de solapaments o comprovacions prèvies sense contaminar el codi del job.

Laravel inclou diversos middleware de job predefinits. El middleware RateLimited limita quants jobs d'un tipus determinat es poden processar per unitat de temps. El middleware WithoutOverlapping evita que dos jobs amb la mateixa clau s'executin simultàniament:

use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\Middleware\WithoutOverlapping;
 
class ProcessPodcast implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
    public function middleware(): array
    {
        return [
            new RateLimited('podcasts'),
            new WithoutOverlapping($this->podcast->id),
        ];
    }
 
    public function handle(): void
    {
        // Processar el podcast
    }
}

El middleware WithoutOverlapping és especialment útil per evitar condicions de carrera. Si dos workers intenten processar el mateix podcast simultàniament, un d'ells esperarà fins que l'altre acabi. La clau que passes al constructor determina l'abast del bloqueig: en aquest cas, $this->podcast->id significa que només es bloquegen els jobs del mateix podcast, no tots els jobs de la classe.

Middleware personalitzat#

Pots crear el teu propi middleware per encapsular qualsevol lògica que necessitis. Per exemple, un middleware que comprova si un servei extern està disponible abans de processar el job:

namespace App\Jobs\Middleware;
 
use Closure;
 
class EnsureServiceAvailable
{
    public function handle(object $job, Closure $next): void
    {
        if (! ExternalService::isAvailable()) {
            // Tornar a encuar el job amb 60 segons de retard
            $job->release(60);
            return;
        }
 
        $next($job);
    }
}
class CallExternalApi implements ShouldQueue
{
    public function middleware(): array
    {
        return [new EnsureServiceAvailable];
    }
 
    public function handle(): void
    {
        // Cridar l'API externa
    }
}

El patró és idèntic al middleware HTTP: reps el job i una closure $next. Si tot està bé, crides $next($job) per continuar. Si no, pots alliberar el job amb release() perquè es torni a intentar més tard, o eliminar-lo amb $job->delete().

Reintentos i backoff#

Les tasques en segon pla sovint depenen de serveis externs: APIs de tercers, servidors de correu, serveis de pagament. Aquests serveis poden estar temporalment no disponibles per manteniment, pics de tràfic o problemes de xarxa. Sense un sistema de reintentos, un error temporal provocaria la pèrdua definitiva de la tasca. Amb reintentos, el job es torna a intentar automàticament fins que funciona o s'exhaureixen els intents.

Laravel ofereix diverses propietats per controlar el comportament dels reintentos. La propietat $tries defineix quantes vegades es pot intentar un job. La propietat $backoff defineix quants segons esperar entre reintentos. La propietat $maxExceptions limita quantes excepcions pot llançar un job abans de considerar-se fallit:

class ProcessPayment implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
    // Nombre màxim d'intents
    public int $tries = 5;
 
    // Segons entre reintentos (exponencial)
    public array $backoff = [10, 30, 60, 120, 300];
 
    // Màxim d'excepcions permeses
    public int $maxExceptions = 3;
 
    // Temps màxim d'execució en segons
    public int $timeout = 120;
 
    public function __construct(
        public Order $order
    ) {}
 
    public function handle(PaymentGateway $gateway): void
    {
        $gateway->charge(
            $this->order->total,
            $this->order->payment_method
        );
 
        $this->order->update(['status' => 'paid']);
    }
}

El backoff exponencial és una estratègia especialment intel·ligent. En lloc d'esperar sempre el mateix temps entre reintentos, cada intent espera més que l'anterior. En l'exemple, el primer reintent espera 10 segons, el segon 30, el tercer 60, el quart 120 i el cinquè 300 (5 minuts). Això dona temps al servei extern per recuperar-se sense sobrecarregar-lo amb peticions continuades.

Limitar per temps en lloc de per intents#

De vegades, en lloc de limitar el nombre d'intents, vols que un job es reintenti indefinidament fins a un moment determinat. El mètode retryUntil permet definir una data límit:

class SyncWithExternalService implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
    // Sense límit d'intents
    public int $tries = 0;
 
    public int $backoff = 30;
 
    // Reintentar durant 24 hores
    public function retryUntil(): \DateTime
    {
        return now()->addHours(24);
    }
 
    public function handle(): void
    {
        // Sincronitzar amb el servei extern
    }
}

Aquesta estratègia és ideal per a jobs que depenen d'un servei extern que pot estar caigut durant hores. En lloc de fallar després de 5 intents en 5 minuts, el job es reintentarà cada 30 segons durant 24 hores senceres. Si el servei es recupera en qualsevol moment durant aquest període, el job s'executarà correctament.

Jobs fallits#

Quan un job exhaureix tots els reintentos o llança una excepció no recuperable, es considera fallit. Laravel mou els jobs fallits a una taula especial failed_jobs que serveix com a registre d'errors. Això permet analitzar què ha fallat i reintentar els jobs manualment un cop resolt el problema.

Per crear la taula de jobs fallits:

php artisan queue:failed-table
php artisan migrate

Gestionar errors dins del job#

Cada job pot definir un mètode failed que s'executa quan el job falla definitivament (després d'exhaurir tots els reintentos). Aquest mètode rep l'excepció que ha causat l'error i és el lloc ideal per notificar l'equip, registrar l'error o prendre mesures correctives:

class ProcessPayment implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
    public int $tries = 3;
 
    public function __construct(
        public Order $order
    ) {}
 
    public function handle(PaymentGateway $gateway): void
    {
        $gateway->charge(
            $this->order->total,
            $this->order->payment_method
        );
 
        $this->order->update(['status' => 'paid']);
    }
 
    public function failed(\Throwable $exception): void
    {
        // Actualitzar l'estat de la comanda
        $this->order->update(['status' => 'payment_failed']);
 
        // Notificar l'equip
        Notification::route('slack', config('services.slack.webhook'))
            ->notify(new PaymentFailedNotification($this->order, $exception));
 
        // Registrar l'error
        Log::error('Pagament fallit', [
            'order_id' => $this->order->id,
            'error' => $exception->getMessage(),
        ]);
    }
}

Comandes per gestionar jobs fallits#

Laravel proporciona diverses comandes Artisan per gestionar els jobs fallits. Aquestes comandes permeten inspeccionar els errors, reintentar jobs individuals o en bloc, i netejar la taula de jobs fallits:

# Llistar tots els jobs fallits
php artisan queue:failed
 
# Reintentar un job fallit específic (per ID)
php artisan queue:retry 5
 
# Reintentar tots els jobs fallits
php artisan queue:retry all
 
# Reintentar els jobs fallits d'una cua específica
php artisan queue:retry --queue=emails
 
# Eliminar un job fallit
php artisan queue:forget 5
 
# Eliminar tots els jobs fallits
php artisan queue:flush
 
# Eliminar jobs fallits de fa més de 48 hores
php artisan queue:prune-failed --hours=48

La comanda queue:retry all és especialment útil quan un servei extern ha estat caigut i ha acumulat molts jobs fallits. Un cop el servei es recupera, pots reintentar-los tots d'un cop. Però ves amb compte: si hi ha milers de jobs fallits, reintentar-los tots simultàniament pot sobrecarregar el sistema. En aquest cas, és millor reintentar-los en lots o augmentar temporalment el nombre de workers.

Job batching#

De vegades necessites executar un grup de jobs i fer alguna acció quan tots hagin acabat. Per exemple, generar 100 informes en paral·lel i enviar un correu quan tots estiguin llestos. El batching de Laravel permet agrupar jobs, monitoritzar el progrés i definir callbacks per quan el batch es completa, falla o acaba (tant si ha tingut èxits com errors).

Primer, cal crear la taula de batches:

php artisan queue:batches-table
php artisan migrate

Els jobs que participen en un batch han d'implementar el trait Batchable:

namespace App\Jobs;
 
use App\Models\Report;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
 
class GenerateReport implements ShouldQueue
{
    use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
    public function __construct(
        public Report $report
    ) {}
 
    public function handle(): void
    {
        // Si el batch ha estat cancel·lat, no processar
        if ($this->batch()->cancelled()) {
            return;
        }
 
        // Generar l'informe
        $this->report->generate();
    }
}

Ara pots crear un batch amb múltiples jobs i definir què passa quan acaba. El mètode then s'executa quan tots els jobs han acabat correctament. El mètode catch s'executa quan el primer job del batch falla. El mètode finally s'executa sempre, tant si el batch ha tingut èxit com si ha fallat:

use App\Jobs\GenerateReport;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
 
$reports = Report::where('status', 'pending')->get();
 
$jobs = $reports->map(function ($report) {
    return new GenerateReport($report);
});
 
$batch = Bus::batch($jobs)
    ->then(function (Batch $batch) {
        // Tots els jobs han acabat correctament
        Notification::route('mail', 'admin@example.com')
            ->notify(new BatchCompleteNotification($batch));
    })
    ->catch(function (Batch $batch, \Throwable $e) {
        // El primer job ha fallat
        Log::error('Batch fallit: ' . $e->getMessage());
    })
    ->finally(function (Batch $batch) {
        // El batch ha acabat (amb o sense errors)
        Log::info("Batch {$batch->id}: {$batch->processedJobs()}/{$batch->totalJobs} completats");
    })
    ->allowFailures()
    ->name('generate-monthly-reports')
    ->onQueue('reports')
    ->dispatch();

El mètode allowFailures() és important: per defecte, quan un job del batch falla, tot el batch es marca com a cancel·lat i els jobs restants no s'executen. Amb allowFailures(), els jobs restants continuen executant-se encara que algun falli. Això és útil quan els jobs són independents i el fracàs d'un no hauria d'afectar els altres.

Monitoritzar el progrés del batch#

Un cop creat el batch, pots consultar el seu estat en qualsevol moment. Això és útil per mostrar barres de progrés a la interfície d'usuari o per a sistemes de monitorització:

use Illuminate\Support\Facades\Bus;
 
$batch = Bus::findBatch($batchId);
 
$batch->id;               // ID del batch
$batch->name;             // Nom del batch
$batch->totalJobs;        // Nombre total de jobs
$batch->pendingJobs;      // Jobs pendents
$batch->processedJobs();  // Jobs processats
$batch->progress();       // Percentatge de progrés (0-100)
$batch->finished();       // Si ha acabat
$batch->cancelled();      // Si ha estat cancel·lat
$batch->failedJobs;       // Nombre de jobs fallits
 
// Cancel·lar un batch
$batch->cancel();

Job chaining#

El chaining permet executar una seqüència de jobs en ordre estricte: el segon job només s'executa quan el primer ha acabat correctament, el tercer quan el segon ha acabat, i així successivament. Si qualsevol job de la cadena falla, els restants no s'executen. Això és diferent del batching, on els jobs s'executen en paral·lel.

El chaining és ideal per a fluxos de treball on cada pas depèn del resultat de l'anterior. Per exemple, processar una comanda pot requerir: primer validar el pagament, després reservar l'estoc, després generar la factura i finalment enviar la confirmació. Cada pas ha d'esperar que l'anterior acabi correctament:

use App\Jobs\ValidatePayment;
use App\Jobs\ReserveStock;
use App\Jobs\GenerateInvoice;
use App\Jobs\SendConfirmation;
use Illuminate\Support\Facades\Bus;
 
Bus::chain([
    new ValidatePayment($order),
    new ReserveStock($order),
    new GenerateInvoice($order),
    new SendConfirmation($order),
])->onQueue('orders')->dispatch();

Pots combinar chaining i callbacks per gestionar errors en la cadena:

Bus::chain([
    new ValidatePayment($order),
    new ReserveStock($order),
    new GenerateInvoice($order),
    new SendConfirmation($order),
])
->catch(function (\Throwable $e) {
    // Algun job de la cadena ha fallat
    Log::error("Error processant la comanda: {$e->getMessage()}");
})
->dispatch();

El callback catch rep l'excepció del job que ha fallat. Això permet prendre mesures correctives com revertir operacions anteriors o notificar l'equip. Tingues en compte que el catch no s'executa si un job falla i té reintentos configurats: el catch només s'activa quan un job falla definitivament (després d'exhaurir tots els reintentos).

Queue workers#

Els workers són processos de llarga durada que escolten les cues i processen els jobs a mesura que arriben. Sense workers actius, els jobs s'acumulen a la cua però no es processen mai. La comanda queue:work inicia un worker:

# Worker bàsic (processa la cua 'default')
php artisan queue:work
 
# Especificar la connexió
php artisan queue:work redis
 
# Especificar la cua
php artisan queue:work --queue=emails
 
# Múltiples cues amb prioritat (primer 'high', després 'default')
php artisan queue:work --queue=high,default
 
# Limitar el nombre d'intents
php artisan queue:work --tries=3
 
# Timeout per job (en segons)
php artisan queue:work --timeout=60
 
# Processar un sol job i sortir
php artisan queue:work --once
 
# Aturar quan la cua estigui buida
php artisan queue:work --stop-when-empty
 
# Limitar l'ús de memòria (en MB)
php artisan queue:work --memory=128

La diferència entre queue:work i queue:listen és important. El worker (queue:work) carrega l'aplicació una sola vegada i processa múltiples jobs sense reiniciar. Això el fa més ràpid, però significa que no detecta canvis al codi automàticament. El listener (queue:listen) reinicia l'aplicació per a cada job, cosa que el fa més lent però reflecteix immediatament qualsevol canvi al codi. En producció, sempre s'ha d'utilitzar queue:work. El queue:listen és útil durant el desenvolupament.

Supervisió amb Supervisor#

En producció, els workers han de funcionar contínuament. Si un worker mor (per un error fatal, per falta de memòria o per un reinici del servidor), ha de tornar a arrencar automàticament. Supervisor és l'eina estàndard per gestionar processos de llarga durada a Linux:

sudo apt-get install supervisor

Crea un fitxer de configuració per als workers de Laravel:

# /etc/supervisor/conf.d/laravel-worker.conf
 
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=3600

La configuració anterior inicia 4 processos worker (definits per numprocs) que es reinicien automàticament si moren. L'opció --max-time=3600 fa que cada worker es reiniciï cada hora, cosa que evita problemes de fuites de memòria. L'opció --sleep=3 fa que el worker esperi 3 segons entre comprovacions quan la cua està buida, reduint el consum de CPU.

# Actualitzar Supervisor amb la nova configuració
sudo supervisorctl reread
sudo supervisorctl update
 
# Iniciar els workers
sudo supervisorctl start laravel-worker:*
 
# Veure l'estat
sudo supervisorctl status

Reiniciar workers després de desplegar#

Quan desplegueu codi nou, els workers existents seguiran executant el codi antic perquè tenen l'aplicació carregada en memòria. Cal reiniciar-los perquè carreguin el codi nou. La comanda queue:restart envia un senyal perquè els workers acabin el job actual i es reiniciïn:

php artisan queue:restart

Aquesta comanda no mata els workers immediatament: espera que acabin el job que estan processant i després es reinicien. Això és important perquè matar un worker a meitat d'un job podria deixar dades en un estat inconsistent. Afegeix aquesta comanda al teu script de desplegament, després de migrate i cache:clear.

Prioritats i múltiples cues#

No tots els jobs tenen la mateixa urgència. Un correu de restabliment de contrasenya s'ha d'enviar en segons, però un informe mensual pot esperar minuts. Les cues amb prioritat permeten processar primer els jobs més urgents sense que els menys urgents bloquegin la cua.

La manera més directa d'implementar prioritats és definir múltiples cues i configurar els workers per processar-les en ordre de prioritat:

// Al job, especificar la cua
class SendPasswordReset implements ShouldQueue
{
    public $queue = 'high';
 
    // ...
}
 
class GenerateMonthlyReport implements ShouldQueue
{
    public $queue = 'low';
 
    // ...
}
 
// O al dispatchar
SendPasswordReset::dispatch($user)->onQueue('high');
GenerateMonthlyReport::dispatch()->onQueue('low');

Després, configura el worker per processar les cues en ordre de prioritat:

php artisan queue:work --queue=high,default,low

Amb aquesta configuració, el worker processarà tots els jobs de la cua high abans de començar amb default, i tots els de default abans de low. Això garanteix que els jobs urgents es processen immediatament, encara que hi hagi centenars de jobs de baixa prioritat esperant.

Una altra estratègia és dedicar workers diferents a cada cua. Això permet assignar més recursos a les cues prioritàries:

# 4 workers per a la cua 'high'
# 2 workers per a la cua 'default'
# 1 worker per a la cua 'low'

Horizon#

Laravel Horizon és un paquet oficial que proporciona un dashboard complet per monitoritzar les cues basades en Redis. Horizon substitueix el worker per defecte per un de propi que ofereix millor rendiment, balanceig automàtic de workers entre cues i una interfície web per veure l'estat de tota la infraestructura de cues en temps real.

Horizon requereix Redis com a driver de cues. Si fas servir un altre driver (database, SQS), Horizon no és compatible. Per instal·lar-lo:

composer require laravel/horizon
php artisan horizon:install
php artisan migrate

La instal·lació crea un fitxer de configuració config/horizon.php on defineixes els entorns, les cues i el nombre de workers. Cada entorn pot tenir una configuració diferent:

// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-1' => [
            'maxProcesses' => 10,
            'balanceMaxShift' => 1,
            'balanceCooldown' => 3,
            'connection' => 'redis',
            'queue' => ['high', 'default', 'low'],
            'balance' => 'auto',
            'autoScalingStrategy' => 'time',
            'minProcesses' => 1,
            'maxProcesses' => 10,
            'tries' => 3,
            'timeout' => 90,
        ],
    ],
 
    'local' => [
        'supervisor-1' => [
            'maxProcesses' => 3,
            'connection' => 'redis',
            'queue' => ['high', 'default', 'low'],
            'balance' => 'simple',
            'tries' => 3,
        ],
    ],
],

L'opció balance controla com Horizon distribueix els workers entre les cues. Amb auto, Horizon assigna automàticament més workers a les cues amb més jobs pendents. Amb simple, els workers es distribueixen equitativament. L'estratègia auto és la més intel·ligent perquè adapta els recursos a la càrrega real en temps real.

Executar Horizon#

En lloc de queue:work, executa Horizon:

php artisan horizon

El dashboard web està disponible a /horizon. Mostra informació detallada sobre cada job: temps d'espera a la cua, temps d'execució, errors, reintentos i molt més. Per protegir el dashboard en producció, configura l'autorització al HorizonServiceProvider:

// app/Providers/HorizonServiceProvider.php
protected function gate(): void
{
    Gate::define('viewHorizon', function ($user) {
        return in_array($user->email, [
            'admin@example.com',
        ]);
    });
}

Notificacions#

Horizon pot enviar notificacions quan la cua es torna massa llarga (quan els jobs esperen massa temps per ser processats). Això és un indicador que necessites més workers o que alguna cosa està fallant:

// app/Providers/HorizonServiceProvider.php
public function boot(): void
{
    parent::boot();
 
    // Notificar si un job espera més de 5 minuts
    Horizon::routeMailNotificationsTo('admin@example.com');
    Horizon::routeSlackNotificationsTo(
        'https://hooks.slack.com/services/xxx',
        '#alertes'
    );
}

Horizon en producció#

En producció, Horizon necessita Supervisor igual que els workers normals. La diferència és que només necessites un procés Supervisor per a Horizon (en lloc d'un per worker), perquè Horizon gestiona internament els workers:

# /etc/supervisor/conf.d/horizon.conf
 
[program:horizon]
process_name=%(program_name)s
command=php /var/www/app/artisan horizon
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/horizon.log
stopwaitsecs=3600

Després de desplegar codi nou, reinicia Horizon:

php artisan horizon:terminate

Igual que queue:restart, Horizon esperarà que els jobs actius acabin abans de reiniciar-se. La comanda horizon:terminate envia un senyal d'aturada elegant que Supervisor detectarà i reiniciarà automàticament el procés.