Queue job failing silently en Laravel — Cómo debuggear
Los jobs de cola que fallan silenciosamente son especialmente frustrantes: el job se despacha, parece que se procesa, pero el resultado esperado nunca ocurre. No hay error visible, no hay excepción en la pantalla. Vamos a ver cómo detectar, debuggear y manejar estos fallos correctamente.
Configurar la tabla failed_jobs
Lo primero es asegurarte de que tienes configurada la tabla de jobs fallidos:
# Crear la migración de failed_jobs (si no existe)
php artisan queue:failed-table
# Migrar
php artisan migrate
En el archivo .env:
QUEUE_CONNECTION=database
QUEUE_FAILED_DRIVER=database-uuids # O simplemente 'database'
Con esto, cualquier job que falle después de agotar sus intentos se guardará en la tabla failed_jobs con el mensaje de error.
Ver jobs fallidos
# Listar todos los jobs fallidos
php artisan queue:failed
# Ver el detalle de un job específico (por su ID)
php artisan queue:failed --queue=default
# Reintentar un job fallido específico
php artisan queue:retry abc-uuid-aqui
# Reintentar todos los jobs fallidos
php artisan queue:retry all
# Borrar un job fallido
php artisan queue:forget abc-uuid-aqui
# Borrar todos los jobs fallidos
php artisan queue:flush
La información más valiosa está en la columna exception de la tabla failed_jobs. Ahí encontrarás el stack trace completo del error.
Ejecutar el worker con —verbose
En desarrollo, ejecuta el worker con el flag verbose para ver todo lo que pasa:
# Ver todos los eventos del worker
php artisan queue:work --verbose
# Procesar solo un job y parar (útil para debugging)
php artisan queue:work --once
# Con más detalle de errores
php artisan queue:work --verbose --tries=1
También puedes usar el driver sync en desarrollo para que los jobs se ejecuten de forma síncrona (sin cola real), lo que facilita ver los errores directamente:
# .env en desarrollo
QUEUE_CONNECTION=sync
Con sync, el job se ejecuta inmediatamente y cualquier excepción aparece en la respuesta HTTP o en la terminal.
Implementar el método failed() en el Job
El método failed() del job se llama automáticamente cuando el job agota todos sus intentos:
<?php
namespace App\Jobs;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class ProcessOrderJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
// Número de intentos antes de marcar como fallido
public int $tries = 3;
// Timeout en segundos
public int $timeout = 120;
// Tiempo entre reintentos (en segundos)
public int $backoff = 60;
public function __construct(
public Order $order
) {}
public function handle(): void
{
// Lógica del job
$this->processPayment($this->order);
$this->sendConfirmationEmail($this->order);
}
/**
* Manejo del fallo del job
*/
public function failed(Throwable $exception): void
{
// Loguear el error
\Log::error("Job ProcessOrderJob falló para order #{$this->order->id}", [
'exception' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
// Notificar al equipo
// Notification::route('slack', config('services.slack.webhook'))
// ->notify(new JobFailedNotification($this->order, $exception));
// Actualizar estado en la BD
$this->order->update(['status' => 'payment_failed']);
}
private function processPayment(Order $order): void
{
// Llamada a servicio de pago
}
private function sendConfirmationEmail(Order $order): void
{
// Envío de email
}
}
Configurar backoff (tiempo entre reintentos)
En lugar de reintentar inmediatamente, puedes configurar un tiempo de espera entre intentos:
// Tiempo fijo entre reintentos
public int $backoff = 60; // 60 segundos entre intentos
// Backoff exponencial (1min, 2min, 4min...)
public function backoff(): array
{
return [60, 120, 240]; // Esperas para cada reintento
}
El gotcha de ShouldBeUnique
ShouldBeUnique evita que el mismo job se procese varias veces en paralelo. Pero si el job falla y hay un lock activo, el siguiente intento también puede fallar silenciosamente porque el lock no se liberó:
use Illuminate\Contracts\Queue\ShouldBeUnique;
class ProcessOrderJob implements ShouldQueue, ShouldBeUnique
{
// La clave única (por defecto usa el nombre de la clase)
public function uniqueId(): string
{
return $this->order->id;
}
// Tiempo que dura el lock (en segundos)
public int $uniqueFor = 3600; // 1 hora
}
Si ves que un job nunca se reintenta, comprueba si tienes ShouldBeUnique y el lock está bloqueando.
Logging dentro de los jobs
Añade logs estratégicos en tus jobs para rastrear el progreso:
public function handle(): void
{
\Log::info("Iniciando ProcessOrderJob", [
'order_id' => $this->order->id,
'attempt' => $this->attempts(),
]);
try {
$result = $this->processPayment($this->order);
\Log::info("Pago procesado exitosamente", [
'order_id' => $this->order->id,
'transaction_id' => $result->transactionId,
]);
} catch (\Exception $e) {
\Log::error("Error procesando pago", [
'order_id' => $this->order->id,
'error' => $e->getMessage(),
'attempt' => $this->attempts(),
]);
// Re-lanzar para que el worker lo marque como fallido
throw $e;
}
}
El $this->attempts() te dice en qué intento va el job, muy útil para diagnóstico.
Reintentar con condiciones específicas
A veces quieres reintentar solo para ciertos tipos de errores:
use Illuminate\Queue\Middleware\RateLimited;
public function handle(): void
{
try {
$this->callExternalApi();
} catch (ApiRateLimitException $e) {
// Reintentar en 5 minutos para rate limits
$this->release(300);
return;
} catch (ApiUnavailableException $e) {
// Reintentar en 1 minuto para errores temporales
$this->release(60);
return;
} catch (\Exception $e) {
// Para otros errores, fallar inmediatamente
$this->fail($e);
}
}
$this->release(seconds) devuelve el job a la cola para reintentarlo después.
$this->fail($exception) marca el job como fallido inmediatamente sin esperar más intentos.
Horizon para monitorización visual
Si usas Laravel Horizon (para Redis), tienes un dashboard visual:
composer require laravel/horizon
php artisan horizon:install
php artisan horizon
Y accede a tu-app.test/horizon para ver el estado de todos los jobs, failed jobs, métricas de throughput, etc.
Conclusión
Los jobs que fallan silenciosamente dejan de ser silenciosos cuando tienes la tabla failed_jobs configurada, implementas el método failed() en tus jobs con logging apropiado, y ejecutas el worker con --verbose en desarrollo. Añade siempre tries, timeout y backoff apropiados a tus jobs para controlar el comportamiento ante fallos.