Saltar al contenido

Un job de Laravel es una tarea que se envía a una cola para ejecutarla en segundo plano. Cuando supera su timeout, el tiempo máximo que tiene para terminar, el worker que lo está ejecutando se termina a sí mismo, y todo lo que el job llevaba hecho en memoria se pierde. El worker es el proceso que arrancas con php artisan queue:work, el que saca los jobs de la cola y los ejecuta uno detrás de otro.

Laravel 13.33 y 13.34, publicadas el 22 y el 29 de septiembre de 2026, añaden 3 formas de controlar ese momento: que el job guarde su progreso antes de que el worker muera, que el worker siga vivo y que sus caídas cuenten como excepciones del job. En esta entrada vamos a ver qué pasa exactamente cuando un job se queda sin tiempo, por qué a veces el timeout no corta nada y cómo usar cada una de las 3 novedades. Todas las salidas que verás son las de un worker real, y todo está probado, en el momento de crear esta entrada, con Laravel 13.35, PHP 8.5 y la cola en base de datos sobre MySQL, con Sail.

Cómo corta Laravel un job

Para cortar un job, Laravel usa señales. Una señal es un aviso que el sistema operativo envía a un proceso: cuando pulsas Ctrl+C en una terminal, por ejemplo, el proceso recibe la señal SIGINT. PHP puede atenderlas con la extensión pcntl, y el worker la usa de la siguiente forma:

  1. Antes de ejecutar cada job, programa una alarma con los segundos de su timeout.
  2. Si el job sigue en marcha cuando se cumple ese tiempo, el sistema envía al worker la señal SIGALRM, la de las alarmas.
  3. Laravel la atiende: comprueba si al job le quedan intentos y, si no le quedan, lo marca como fallido.
  4. Por último, el worker se envía a sí mismo SIGKILL, la señal que termina un proceso al instante, sin dejarle ejecutar ni una línea más.

Ese último paso es deliberado: un job que no responde puede estar bloqueado, y la forma segura de liberarlo es terminar el proceso entero. El problema es que con el proceso se va todo lo que el job tenía en memoria.

Requisitos

La propiedad Worker::$killOnTimeout llegó en Laravel 13.33 (PR #61591). Laravel 13.34 trae el aviso del timeout a los jobs que implementan la interfaz Interruptible (PR #61651) y el atributo CountCrashesAsExceptions (PR #61737), como recogen sus notas de la versión.

El timeout necesita además la extensión pcntl de PHP, que no está disponible en Windows. Sin ella, el worker no programa la alarma y los jobs no tienen límite de tiempo. Sail la trae instalada, y puedes comprobarlo con php -m:

php -m | grep pcntl
pcntl

Partimos de un proyecto de Laravel 13 con Sail y la cola en base de datos, que es la que viene configurada por defecto (QUEUE_CONNECTION=database en el .env). Los comandos de esta entrada se ejecutan en el contenedor de la aplicación: abre una terminal en él con ./vendor/bin/sail shell y lanza ahí cada php artisan.

Un job que tarda más que su timeout

Vamos a simular una importación de productos que necesita más tiempo del que le damos. Crea el modelo con su migración y el job:

php artisan make:model Import -m
php artisan make:job ProcessImport

Abre la migración que se acaba de crear y deja el método up() de la siguiente forma:

    public function up(): void
    {
        Schema::create('imports', function (Blueprint $table) {
            $table->id();
            $table->unsignedInteger('rows');
            $table->unsignedInteger('processed')->default(0);
            $table->timestamps();
        });
    }

Cada importación guarda cuántas filas tiene (rows) y cuántas lleva procesadas (processed). En el modelo, app/Models/Import.php, permite la asignación masiva de los 2 campos:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;

#[Fillable(['rows', 'processed'])]
class Import extends Model
{
    //
}

Y ejecuta la migración:

php artisan migrate

Ahora abre app/Jobs/ProcessImport.php y cambia el código por el siguiente:

<?php

namespace App\Jobs;

use App\Models\Import;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Support\Facades\Log;

#[Timeout(5)]
class ProcessImport implements ShouldQueue
{
    use Queueable;

    private int $processed = 0;

    public function __construct(public Import $import) {}

    public function handle(): void
    {
        $this->processed = $this->import->processed;

        Log::info("Intento {$this->attempts()}: empieza en la fila {$this->processed}");

        while ($this->processed < $this->import->rows) {
            $this->importRow($this->processed + 1);

            $this->processed++;
        }

        $this->import->update(['processed' => $this->processed]);

        Log::info("Importación terminada con {$this->processed} filas");
    }

    private function importRow(int $row): void
    {
        usleep(500_000);
    }
}

Si te fijas, el atributo #[Timeout(5)] le da al job 5 segundos como máximo. Este valor manda sobre la opción --timeout del worker, que por defecto es de 60 segundos. Cada fila tarda medio segundo en importRow(), donde usleep() hace de tarea lenta: en una aplicación sería la llamada a una API, a un modelo de IA o el procesado de un archivo. Con 20 filas, el job necesita 10 segundos, el doble de lo que tiene. El progreso se lleva en $processed, en memoria, y solo se guarda en la base de datos al terminar.

Vamos a enviar a la cola una importación de 20 filas con Tinker:

php artisan tinker --execute="App\Jobs\ProcessImport::dispatch(App\Models\Import::create(['rows' => 20]));"

Y a arrancar el worker:

php artisan queue:work
  2026-10-12 21:03:20 App\Jobs\ProcessImport ......................... RUNNING
  2026-10-12 21:03:25 App\Jobs\ProcessImport ......................... 5s FAIL
  2026-10-12 21:03:25 Worker STOPPED Job timed out

A los 5 segundos, el job falla y el worker se para con Job timed out. Bash avisa de que el proceso ha terminado con Killed, y su código de salida es 137, que es 128 más el 9 de SIGKILL. El worker no vuelve a arrancar por su cuenta: en producción, eso lo hace Supervisor, el gestor de procesos que recomienda la documentación de Laravel.

Como el worker da por defecto un solo intento a cada job (--tries=1), el job ya está en la lista de fallidos:

php artisan queue:failed
  2026-10-12 21:03:25 2a6b6abf-f3ce-46d2-a7ed-a9a37bd4852a  database@default App\Jobs\ProcessImport

En la columna exception de la tabla failed_jobs se guarda la excepción que lo explica:

Illuminate\Queue\TimeoutExceededException: App\Jobs\ProcessImport has timed out.

En el log de la aplicación, storage/logs/laravel.log, solo está la línea de inicio, y la importación sigue con processed a 0: las filas que el job había procesado se han perdido con el proceso.

Los reintentos empiezan de cero

Vamos a darle 3 intentos al job con el atributo Tries. En app/Jobs/ProcessImport.php, importa la clase y añade el atributo debajo del timeout:

use Illuminate\Queue\Attributes\Tries;

#[Timeout(5)]
#[Tries(3)]
class ProcessImport implements ShouldQueue

Como el worker muere en cada timeout, vamos a hacer lo mismo que Supervisor con un bucle de bash que lo vuelve a lanzar cada vez que termina con error:

until php artisan queue:work; do sleep 1; done

until repite el comando mientras falle. Cuando quieras pararlo, pulsa Ctrl+C: el worker termina bien, con código 0, y el bucle se acaba.

Envía otra importación con el mismo comando de Tinker, arranca el bucle y espera a que termine el tercer intento. Este es el log:

[2026-10-12 21:03:30] local.INFO: Intento 1: empieza en la fila 0
[2026-10-12 21:05:02] local.INFO: Intento 2: empieza en la fila 0
[2026-10-12 21:06:32] local.INFO: Intento 3: empieza en la fila 0

Cada intento empieza en la fila 0, así que el job nunca llega al final, y después del tercer timeout acaba en failed_jobs con la misma TimeoutExceededException. Fíjate también en las horas: entre un intento y el siguiente pasan 92 y 90 segundos, aunque el bucle vuelve a lanzar el worker al segundo.

Ese tiempo es el retry_after de la conexión database en config/queue.php, 90 segundos por defecto. Cuando un worker coge un job, lo marca como reservado en la tabla jobs, y otro worker solo lo puede volver a coger cuando pasa ese tiempo sin que nadie lo haya borrado ni devuelto a la cola. Un worker que muere no devuelve nada, así que el job espera los 90 segundos completos.

Por eso la documentación insiste en que el timeout de un job sea siempre varios segundos menor que retry_after: si es mayor, otro worker puede empezar el mismo job mientras el primero todavía lo está ejecutando. Si tus jobs necesitan más de 90 segundos, sube DB_QUEUE_RETRY_AFTER en el .env.

Guardar el progreso con Interruptible

La interfaz Interruptible llegó en Laravel 13.7 para avisar al job de las señales que paran el worker, como SIGTERM, la que se usa normalmente para pedir a un proceso que termine. Desde Laravel 13.34, el worker también le pasa SIGALRM cuando se cumple el timeout, antes de hacer nada más, y eso le da al job una última oportunidad de guardar lo que lleva.

Vamos a usarla en app/Jobs/ProcessImport.php. Deja el job de la siguiente forma:

<?php

namespace App\Jobs;

use App\Models\Import;
use Illuminate\Contracts\Queue\Interruptible;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Support\Facades\Log;

#[Timeout(5)]
#[Tries(3)]
class ProcessImport implements ShouldQueue, Interruptible
{
    use Queueable;

    private int $processed = 0;

    public function __construct(public Import $import) {}

    public function handle(): void
    {
        $this->processed = $this->import->processed;

        Log::info("Intento {$this->attempts()}: empieza en la fila {$this->processed}");

        while ($this->processed < $this->import->rows) {
            $this->importRow($this->processed + 1);

            $this->processed++;
        }

        $this->import->update(['processed' => $this->processed]);

        Log::info("Importación terminada con {$this->processed} filas");
    }

    public function interrupted(int $signal): void
    {
        $this->import->update(['processed' => $this->processed]);

        Log::warning("Señal {$signal}: progreso guardado en la fila {$this->processed}");
    }

    private function importRow(int $row): void
    {
        usleep(500_000);
    }
}

Si te fijas, el job implementa ahora Interruptible, y su método interrupted() recibe el número de la señal y guarda en la base de datos las filas procesadas hasta ese momento. Al empezar, handle() ya leía processed de la importación, así que cada intento sigue donde se quedó el anterior.

El progreso se guarda en el propio interrupted(), y no en handle(), porque después de llamarlo el worker se mata y handle() no vuelve a ejecutar ni una línea. El ejemplo de la documentación, que marca una propiedad para que handle() salga del bucle y guarde, sirve para SIGTERM, pero no para el timeout.

Envía otra importación y arranca el bucle del worker. Este es el log:

[2026-10-12 21:06:42] local.INFO: Intento 1: empieza en la fila 0
[2026-10-12 21:06:47] local.WARNING: Señal 14: progreso guardado en la fila 9
[2026-10-12 21:08:14] local.INFO: Intento 2: empieza en la fila 9
[2026-10-12 21:08:19] local.WARNING: Señal 14: progreso guardado en la fila 18
[2026-10-12 21:09:44] local.INFO: Intento 3: empieza en la fila 18
[2026-10-12 21:09:45] local.INFO: Importación terminada con 20 filas

La señal 14 es SIGALRM. El primer intento llega a la fila 9, el segundo sigue desde ahí hasta la 18 y el tercero termina las que faltan. La importación acaba con sus 20 filas y no hay ningún job en failed_jobs. Entre un intento y otro siguen pasando los 90 segundos de retry_after, pero ya no se pierde nada.

El mismo método recibe las señales con las que se para el worker: SIGINT (Ctrl+C), SIGTERM y SIGQUIT. En esos casos el job no muere: sigue hasta el final y después se para el worker. Vamos a comprobarlo con una importación de 6 filas. Envíala con Tinker, arranca el worker con php artisan queue:work y, mientras la procesa, envíale SIGTERM desde otra terminal del contenedor:

pkill -TERM -f 'artisan queue:work'
  2026-10-12 21:09:51 App\Jobs\ProcessImport ......................... RUNNING
  2026-10-12 21:09:54 App\Jobs\ProcessImport ......................... 2s DONE
  2026-10-12 21:09:54 Worker STOPPED Interrupted
[2026-10-12 21:09:51] local.INFO: Intento 1: empieza en la fila 0
[2026-10-12 21:09:52] local.WARNING: Señal 15: progreso guardado en la fila 2
[2026-10-12 21:09:54] local.INFO: Importación terminada con 6 filas

La señal 15 es SIGTERM. El progreso se guarda en la fila 2, pero el job sigue hasta la 6 y el worker se para después con Worker STOPPED Interrupted. Si solo quieres guardar el progreso en el timeout, compara la señal con la constante SIGALRM en interrupted().

Cuando el timeout no corta el job

Hay 2 situaciones en las que el timeout llega tarde o no llega.

Una llamada HTTP que tarda en responder

La alarma suena a su hora, pero PHP no ejecuta el código que la atiende hasta que termina la función en curso. Con usleep() no se nota, porque la señal la interrumpe, pero con una petición HTTP sí. Vamos a comprobarlo con una ruta de nuestra propia aplicación que tarda 15 segundos en responder, como un servicio externo lento. Añádela en routes/web.php:

Route::get('/lento', function () {
    sleep(15);

    return 'informe';
});

Crea el job:

php artisan make:job FetchSlowReport

Y en app/Jobs/FetchSlowReport.php, vamos a pedir esa ruta con el cliente HTTP de Laravel, con 30 segundos de timeout para la petición, más que los 5 del job:

<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

#[Timeout(5)]
class FetchSlowReport implements ShouldQueue
{
    use Queueable;

    public function handle(): void
    {
        Log::info('Petición enviada');

        $response = Http::timeout(30)->get('http://localhost/lento');

        Log::info("Respuesta recibida: {$response->body()}");
    }
}

Con Sail, la aplicación responde en el puerto 80 del contenedor, y por eso la URL es http://localhost/lento. Envía el job a la cola y arranca el worker:

php artisan tinker --execute="App\Jobs\FetchSlowReport::dispatch();"
php artisan queue:work
  2026-10-12 21:09:58 App\Jobs\FetchSlowReport ....................... RUNNING
  2026-10-12 21:10:16 App\Jobs\FetchSlowReport ...................... 17s FAIL
  2026-10-12 21:10:16 Worker STOPPED Job timed out

El job tenía 5 segundos, pero el worker no lo corta hasta que llega la respuesta, 17 segundos después. Y esa respuesta se pierde: en el log está Petición enviada, pero no Respuesta recibida, porque el worker se mata nada más atender la alarma.

La solución es que cada llamada tenga su propio timeout, menor que el del job, como recomienda la documentación. Cambia la petición:

$response = Http::timeout(4)->get('http://localhost/lento');

Y repite la prueba:

  2026-10-12 21:10:20 App\Jobs\FetchSlowReport ....................... RUNNING
  2026-10-12 21:10:24 App\Jobs\FetchSlowReport ....................... 3s FAIL
[2026-10-12 21:10:20] local.INFO: Petición enviada
[2026-10-12 21:10:24] local.ERROR: cURL error 28: Operation timed out after 4049 milliseconds with 0 bytes received (see https://curl.se/libcurl/c/libcurl-errors.html) for http://localhost/lento

Ahora la petición se corta a los 4 segundos con una ConnectionException, el job falla como con cualquier otra excepción y el worker sigue vivo, esperando el siguiente job.

El worker con --once

Con la opción --once, el worker ejecuta un solo job y termina. En ese modo no programa la alarma, así que el timeout no hace nada, como avisa la documentación. Con otra importación de 20 filas:

php artisan queue:work --once
  2026-10-12 21:10:31 App\Jobs\ProcessImport ......................... RUNNING
  2026-10-12 21:10:41 App\Jobs\ProcessImport ........................ 10s DONE

El job dura 10 segundos, con un timeout de 5, y termina sin que nadie lo corte.

Cuando el worker se cae

El timeout no es la única forma de que un worker muera a mitad de un job. Si el servidor se queda sin memoria, por ejemplo, Linux puede matar el proceso con SIGKILL. En ese caso Laravel no llega a enterarse: el job vuelve a la cola cuando pasa retry_after y se reintenta.

Cada caída gasta un intento, así que un job con #[Tries(3)] acaba fallando. El problema está en los jobs que no limitan los intentos y se paran con maxExceptions, el número de excepciones que admiten antes de fallar, y con retryUntil(), la hora a partir de la cual dejan de reintentarse. Es la configuración que sugiere la documentación para los jobs que vuelven a la cola muchas veces sin haber fallado, como los que usan el middleware RateLimited, porque cada vuelta también gasta un intento.

Vamos a verlo con un job que sincroniza un catálogo. Créalo:

php artisan make:job SyncCatalog

Y en app/Jobs/SyncCatalog.php, deja el siguiente código:

<?php

namespace App\Jobs;

use DateTime;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Support\Facades\Log;

#[Tries(0)]
#[MaxExceptions(2)]
class SyncCatalog implements ShouldQueue
{
    use Queueable;

    public function retryUntil(): DateTime
    {
        return now()->addHour();
    }

    public function handle(): void
    {
        Log::info("Intento {$this->attempts()}: sincronizando el catálogo");

        sleep(30);

        Log::info('Catálogo sincronizado');
    }
}

#[Tries(0)] quita el límite de intentos, #[MaxExceptions(2)] hace que el job falle a la segunda excepción y retryUntil() deja de reintentarlo pasada una hora. El sleep(30) nos da tiempo para matar el worker.

Envía el job a la cola con Tinker y arranca el worker con el bucle de antes:

php artisan tinker --execute="App\Jobs\SyncCatalog::dispatch();"
until php artisan queue:work; do sleep 1; done

Cuando aparezca RUNNING, simula la caída desde otra terminal del contenedor:

pkill -9 -f 'artisan queue:work'

Y repítelo en el segundo intento, cuando el job vuelva a la cola. Este es el log:

[2026-10-12 21:10:50] local.INFO: Intento 1: sincronizando el catálogo
[2026-10-12 21:12:20] local.INFO: Intento 2: sincronizando el catálogo
[2026-10-12 21:13:51] local.INFO: Intento 3: sincronizando el catálogo
[2026-10-12 21:14:24] local.INFO: Catálogo sincronizado

Las 2 caídas no cuentan como excepciones, así que el tercer intento empieza como si nada y esta vez termina. Si el job se cayera siempre, se seguiría reintentando cada 90 segundos durante una hora.

Desde Laravel 13.34, el atributo CountCrashesAsExceptions hace que cada caída cuente como una excepción. En app/Jobs/SyncCatalog.php, importa la clase y añade el atributo:

use Illuminate\Queue\Attributes\CountCrashesAsExceptions;

#[Tries(0)]
#[MaxExceptions(2)]
#[CountCrashesAsExceptions]
class SyncCatalog implements ShouldQueue

Con las mismas 2 caídas, el worker muestra lo siguiente:

  2026-10-12 21:14:29 App\Jobs\SyncCatalog ........................... RUNNING
  2026-10-12 21:16:00 App\Jobs\SyncCatalog ........................... RUNNING
  2026-10-12 21:17:30 App\Jobs\SyncCatalog ........................... RUNNING
  2026-10-12 21:17:30 App\Jobs\SyncCatalog ...................... 17.46ms FAIL

Y el log:

[2026-10-12 21:14:29] local.INFO: Intento 1: sincronizando el catálogo
[2026-10-12 21:16:00] local.INFO: Intento 2: sincronizando el catálogo
[2026-10-12 21:17:30] local.ERROR: App\Jobs\SyncCatalog has been attempted too many times.

El tercer intento falla nada más empezar, sin llegar a ejecutar handle(), con una MaxAttemptsExceededException. Para saber si el intento anterior se cayó, el worker guarda una marca en la caché de la aplicación al empezar cada intento y la borra al terminar. Si al empezar el siguiente la marca sigue ahí, el anterior no terminó, y eso cuenta como una excepción.

Que el worker no muera con $killOnTimeout

Laravel 13.33 añade la propiedad estática Worker::$killOnTimeout. Por defecto tiene el valor true y el worker se comporta como hemos visto hasta ahora. Con el valor false, el worker no se mata: lanza una TimeoutExceededException en el punto del job en el que estaba cuando sonó la alarma, y sigue con el siguiente job. En el momento de crear esta entrada, la documentación de Laravel todavía no la menciona.

Vamos a activarla en app/Providers/AppServiceProvider.php:

<?php

namespace App\Providers;

use Illuminate\Queue\Worker;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Worker::$killOnTimeout = false;
    }
}

Vamos a volver a la importación de 20 filas, con el job que guarda su progreso en interrupted(). Envía otra con Tinker y arranca el worker, esta vez sin el bucle, porque ya no muere:

php artisan queue:work
  2026-10-12 21:17:35 App\Jobs\ProcessImport ......................... RUNNING
  2026-10-12 21:17:40 App\Jobs\ProcessImport ......................... 5s FAIL
  2026-10-12 21:17:40 App\Jobs\ProcessImport ......................... RUNNING
  2026-10-12 21:17:45 App\Jobs\ProcessImport ......................... 5s FAIL
  2026-10-12 21:17:45 App\Jobs\ProcessImport ......................... RUNNING
  2026-10-12 21:17:46 App\Jobs\ProcessImport ......................... 1s DONE
[2026-10-12 21:17:35] local.INFO: Intento 1: empieza en la fila 0
[2026-10-12 21:17:40] local.WARNING: Señal 14: progreso guardado en la fila 9
[2026-10-12 21:17:40] local.ERROR: App\Jobs\ProcessImport has timed out.
[2026-10-12 21:17:40] local.INFO: Intento 2: empieza en la fila 9
[2026-10-12 21:17:45] local.WARNING: Señal 14: progreso guardado en la fila 18
[2026-10-12 21:17:45] local.ERROR: App\Jobs\ProcessImport has timed out.
[2026-10-12 21:17:45] local.INFO: Intento 3: empieza en la fila 18
[2026-10-12 21:17:46] local.INFO: Importación terminada con 20 filas

La importación termina en 11 segundos, frente a los más de 3 minutos que tardaba con el worker muriendo en cada timeout. Como el worker sigue vivo, el job vuelve a la cola nada más fallar, sin esperar a retry_after, y lo coge el mismo worker. interrupted() se sigue llamando antes de la excepción, y la excepción queda en el log como cualquier otra.

Esta propiedad tiene 2 problemas que conviene conocer antes de activarla.

Un try/catch puede ocultar el timeout

Como el timeout llega ahora como una excepción, un catch del job la puede capturar. Es lo que pasa con algo muy habitual en las importaciones: capturar el error de cada fila para registrarlo y seguir con la siguiente. Por ejemplo, con este bucle en handle():

use Throwable;

// ...

while ($this->processed < $this->import->rows) {
    try {
        $this->importRow($this->processed + 1);
    } catch (Throwable $e) {
        Log::error('Fila '.($this->processed + 1).": {$e->getMessage()}");
    }

    $this->processed++;
}

Con otra importación de 20 filas, el worker y el log muestran lo siguiente:

  2026-10-12 21:17:55 App\Jobs\ProcessImport ......................... RUNNING
  2026-10-12 21:18:05 App\Jobs\ProcessImport ........................ 10s DONE
[2026-10-12 21:17:55] local.INFO: Intento 1: empieza en la fila 0
[2026-10-12 21:18:00] local.WARNING: Señal 14: progreso guardado en la fila 9
[2026-10-12 21:18:00] local.ERROR: Fila 10: App\Jobs\ProcessImport has timed out.
[2026-10-12 21:18:05] local.INFO: Importación terminada con 20 filas

El catch trata el timeout como el error de una fila, que además se da por procesada, y el job sigue hasta el final: termina con DONE a los 10 segundos, con un timeout de 5. Con el worker por defecto esto no puede pasar, porque el proceso muere.

La solución es dejar pasar la TimeoutExceededException antes del catch genérico:

use Illuminate\Queue\TimeoutExceededException;

// ...

while ($this->processed < $this->import->rows) {
    try {
        $this->importRow($this->processed + 1);
    } catch (TimeoutExceededException $e) {
        throw $e;
    } catch (Throwable $e) {
        Log::error('Fila '.($this->processed + 1).": {$e->getMessage()}");
    }

    $this->processed++;
}

Con este cambio, la importación vuelve a cortarse a los 5 segundos y termina en 3 intentos, como antes:

  2026-10-12 21:18:11 App\Jobs\ProcessImport ......................... RUNNING
  2026-10-12 21:18:16 App\Jobs\ProcessImport ......................... 5s FAIL
  2026-10-12 21:18:16 App\Jobs\ProcessImport ......................... RUNNING
  2026-10-12 21:18:21 App\Jobs\ProcessImport ......................... 5s FAIL
  2026-10-12 21:18:21 App\Jobs\ProcessImport ......................... RUNNING
  2026-10-12 21:18:25 App\Jobs\ProcessImport ......................... 3s DONE

maxExceptions cuenta cada timeout 2 veces

Con $killOnTimeout en false, cada timeout suma 2 excepciones para maxExceptions: una cuando el worker atiende la alarma y otra cuando gestiona la excepción que acaba de lanzar. Vamos a verlo con una importación de 60 filas, que necesita varios intentos para terminar. En app/Jobs/ProcessImport.php, importa la clase y deja los atributos así:

use Illuminate\Queue\Attributes\MaxExceptions;

#[Timeout(5)]
#[Tries(10)]
#[MaxExceptions(3)]
class ProcessImport implements ShouldQueue, Interruptible
  2026-10-12 21:18:31 App\Jobs\ProcessImport ......................... RUNNING
  2026-10-12 21:18:36 App\Jobs\ProcessImport ......................... 5s FAIL
  2026-10-12 21:18:36 App\Jobs\ProcessImport ......................... RUNNING
  2026-10-12 21:18:41 App\Jobs\ProcessImport ......................... 5s FAIL
[2026-10-12 21:18:31] local.INFO: Intento 1: empieza en la fila 0
[2026-10-12 21:18:36] local.WARNING: Señal 14: progreso guardado en la fila 9
[2026-10-12 21:18:36] local.ERROR: App\Jobs\ProcessImport has timed out.
[2026-10-12 21:18:36] local.INFO: Intento 2: empieza en la fila 9
[2026-10-12 21:18:41] local.WARNING: Señal 14: progreso guardado en la fila 18
[2026-10-12 21:18:41] local.ERROR: App\Jobs\ProcessImport has timed out.

El job falla en el segundo timeout, no en el tercero, y pasa a failed_jobs sin un tercer intento. Con el worker por defecto, el mismo job falla en el tercer timeout, como se espera. Si usas $killOnTimeout en false con MaxExceptions, ten en cuenta que el número de timeouts que admite el job se queda en la mitad.

Con esto, un job largo puede cortarse sin perder lo que lleva hecho. Si estás empezando con las colas, tienes lo básico en nuestra guía de jobs en Laravel, y si quieres seguir aprendiendo, no te pierdas el curso de jobs y colas en Laravel. Espero que te sirva.

school Curso completo

Curso Laravel 12
Completo 2026

El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.

access_time 8 horas de contenido
layers 4 tecnologías en 1
update 100% actualizado
code Proyectos prácticos
Ver Curso Laravel 12 arrow_forward

star Incluido en cualquier suscripción

Rutas de aprendizaje