Job Batching en Laravel: procesar ficheros CSV grandes sin bloquear la aplicación

Tienes un CSV con cien mil filas de productos y te piden importarlo al sistema. La solución rápida: leerlo en el controlador, recorrerlo con un foreach e insertar en base de datos. Resultado: timeout a los 30 segundos, proceso de PHP muerto con 500 MB de RAM consumidos y el usuario mirando una pantalla en blanco.

El problema no es PHP. Es que estás haciendo trabajo de fondo dentro de una petición HTTP, que tiene un tiempo de vida corto y un presupuesto de memoria ajustado. La solución pasa por tres cosas: dividir el CSV en trozos pequeños, convertir cada trozo en un job y procesarlos en background con una queue. Laravel tiene exactamente la herramienta para esto: Job Batching.

Qué es Job Batching en Laravel

Job Batching, introducido en Laravel 8, permite agrupar varios jobs en un lote (batch) y hacer seguimiento de su progreso como conjunto. La API es Bus::batch([...jobs...]) y te da tres callbacks clave:

  • then(): se ejecuta cuando todos los jobs del lote terminan correctamente.
  • catch(): se dispara si algún job falla (y no tiene más reintentos).
  • finally(): se ejecuta siempre, haya fallado o no algún job. Útil para limpiar ficheros temporales o enviar una notificación.

Para usar batching necesitas una tabla extra en la base de datos. La creas así:

php artisan queue:batches-table
php artisan migrate

Esto genera la tabla job_batches, donde Laravel guarda el estado de cada lote: cuántos jobs tiene, cuántos han terminado, cuántos han fallado y si el lote está completo.

Leer el CSV por chunks sin reventar la memoria

Antes de crear los jobs, necesitas leer el CSV de forma eficiente. El error más común es hacer file_get_contents() o fgetcsv() dentro de un bucle que carga todo en un array. Con cien mil filas eso puede consumir varios cientos de megabytes.

La alternativa con PHP nativo usa SplFileObject, que lee línea a línea sin cargar el fichero entero:

$file = new SplFileObject('/ruta/al/archivo.csv');
$file->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY);

$chunk = [];
$jobs  = [];

foreach ($file as $row) {
    if ($file->key() === 0) continue; // saltar cabecera

    $chunk[] = $row;

    if (count($chunk) === 500) {
        $jobs[]  = new ProcessCsvChunk($chunk);
        $chunk   = [];
    }
}

// último trozo si no llegó a 500
if (!empty($chunk)) {
    $jobs[] = new ProcessCsvChunk($chunk);
}

Si prefieres algo más cómodo, LeagueCsv es una librería ligera que gestiona bien los encodings y las cabeceras:

use LeagueCsvReader;

$csv     = Reader::createFromPath('/ruta/al/archivo.csv', 'r');
$csv->setHeaderOffset(0);
$records = $csv->getRecords();

Con getRecords() obtienes un iterador, no un array, así que no carga todo en memoria. Luego acumulas filas en $chunk igual que en el ejemplo anterior.

Crear el job de procesamiento

Genera el job con Artisan:

php artisan make:job ProcessCsvChunk

El job recibe el array de filas y las inserta en base de datos. Lo importante aquí es la idempotencia: si el job falla y Laravel lo reintenta, no debe duplicar datos. Para eso usa insertOrIgnore() o upsert() en lugar de un insert() simple:

<?php

namespace AppJobs;

use IlluminateBusBatchable;
use IlluminateBusQueueable;
use IlluminateContractsQueueShouldQueue;
use IlluminateFoundationBusDispatchable;
use IlluminateQueueInteractsWithQueue;
use IlluminateQueueSerializesModels;
use IlluminateSupportFacadesDB;

class ProcessCsvChunk implements ShouldQueue
{
    use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries   = 3;
    public array $backoff = [10, 30, 60];

    public function __construct(private array $rows) {}

    public function handle(): void
    {
        // Si el batch fue cancelado, no procesamos
        if ($this->batch()->cancelled()) {
            return;
        }

        $data = array_map(fn($row) => [
            'sku'        => $row[0],
            'nombre'     => $row[1],
            'precio'     => (float) $row[2],
            'stock'      => (int) $row[3],
            'updated_at' => now(),
        ], $this->rows);

        DB::table('productos')->upsert(
            $data,
            ['sku'],           // columna clave para detectar duplicados
            ['nombre', 'precio', 'stock', 'updated_at']
        );
    }
}

El trait Batchable es lo que da al job acceso a $this->batch(). Sin él, el job no sabrá que pertenece a un lote y no actualizará el contador de progreso.

Despachar el batch desde el controlador

Una vez tienes el array de jobs, los despachas todos de golpe:

use IlluminateSupportFacadesBus;

$batch = Bus::batch($jobs)
    ->name('importacion-productos')
    ->allowFailures()
    ->then(function (Batch $batch) {
        // todos los jobs terminaron bien
        Log::info("Importación completada: {$batch->totalJobs} filas procesadas.");
    })
    ->catch(function (Batch $batch, Throwable $e) {
        Log::error("Fallo en la importación: " . $e->getMessage());
    })
    ->finally(function (Batch $batch) {
        // limpiar fichero temporal, notificar al usuario, etc.
        Cache::forget('importacion_en_curso');
    })
    ->dispatch();

return response()->json(['batch_id' => $batch->id]);

allowFailures() es clave: sin él, el primer job que falle cancela todo el lote. Con él, los demás jobs siguen corriendo aunque haya fallos puntuales. Para una importación de CSV suele ser la opción más práctica, porque perder quinientas filas por un error puntual es mejor que cancelar cien mil.

Monitorizar el progreso

El batch devuelve un $batch->id que puedes guardar en sesión o devolver al cliente. Luego creas un endpoint que consulte el estado:

use IlluminateSupportFacadesBus;

public function progreso(string $batchId)
{
    $batch = Bus::findBatch($batchId);

    if (!$batch) {
        return response()->json(['error' => 'Batch no encontrado'], 404);
    }

    return response()->json([
        'progreso'   => $batch->progress(),        // 0-100
        'total'      => $batch->totalJobs,
        'pendientes' => $batch->pendingJobs,
        'procesados' => $batch->processedJobs(),
        'fallidos'   => $batch->failedJobs,
        'completado' => $batch->finished(),
        'cancelado'  => $batch->cancelled(),
    ]);
}

Desde el frontend haces polling a ese endpoint cada dos o tres segundos y actualizas una barra de progreso. No hace falta WebSockets para esto; un setInterval sencillo con fetch() es suficiente para importaciones que tardan minutos.

Gestión de errores y reintentos

En el job ya definiste $tries = 3 y $backoff = [10, 30, 60]. Esto significa que si el job falla, Laravel esperará 10 segundos antes del segundo intento, 30 antes del tercero y 60 antes del cuarto. Si agota los tres reintentos, el job va a la tabla failed_jobs.

Para ver los jobs fallidos:

php artisan queue:failed

Para reencolarlos todos:

php artisan queue:retry all

O uno concreto por su UUID:

php artisan queue:retry 5b7d2c1a-...

Si quieres que los reintentos de un batch fallido no interfieran con otros batches, añade ->onQueue('importaciones') al despachar y levanta un worker dedicado para esa queue:

php artisan queue:work --queue=importaciones

Alternativa: Laravel Excel con chunked reading

Si el proyecto ya usa MaatwebsiteExcel (Laravel Excel), la importación masiva con queue está prácticamente resuelta. Solo tienes que implementar dos interfaces en tu clase de importación:

use MaatwebsiteExcelConcernsToModel;
use MaatwebsiteExcelConcernsWithChunkReading;
use MaatwebsiteExcelConcernsShouldQueue;

class ProductosImport implements ToModel, WithChunkReading, ShouldQueue
{
    public function model(array $row)
    {
        return new Producto([
            'sku'    => $row[0],
            'nombre' => $row[1],
            'precio' => $row[2],
        ]);
    }

    public function chunkSize(): int
    {
        return 500;
    }
}

Con WithChunkReading y ShouldQueue, Laravel Excel se encarga de dividir el fichero en chunks y despacharlos como jobs. Menos código, menos control. Tiene sentido cuando el modelo de datos es sencillo y no necesitas lógica de progreso personalizada. Si tienes validaciones complejas, transformaciones o necesitas el batch_id para hacer polling, el enfoque manual con Bus::batch() te da más margen.

Para importaciones que van más allá del típico CSV de productos, como migraciones entre sistemas o cargas de datos históricos, te puede interesar revisar cómo encaja esto con la arquitectura de procesos en background en Laravel. Y si el volumen de datos te preocupa desde el punto de vista del rendimiento general, hay más contexto en el artículo sobre rendimiento en operaciones de alta carga con PHP.

Job Batching no es magia, pero sí es la herramienta correcta para este problema: mueve el trabajo pesado fuera de la petición HTTP, da visibilidad sobre el progreso y gestiona los fallos de forma controlada. Un CSV de cien mil filas deja de ser un problema cuando lo conviertes en doscientos jobs de quinientas filas cada uno.

Imagen: Pexels / panumas nikhomkhai

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP