← Volver al blog

Subidas de archivos resumibles y en chunks con Laravel, Next.js y Redis: guía paso a paso

2026-10-14

Escenario real: el agente te dice “subida completa” y el archivo se pierde

Imagina que tu asistente de IA está procesando un video de 2 GB que el usuario acaba de subir. El agente muestra el mensaje “¡Todo listo!” y, al intentar reproducir el video, descubres que el archivo está corrupto o falta parte de él. No hay orquestador ni cola que garantice la entrega; la única pieza de infraestructura que controla el flujo es el propio agente que reporta el estado.

En este punto la solución no es “agregar más logs” sino asegurarse de que la subida sea resumible y en chunks. Si el agente puede preguntar “¿está completo?” y la respuesta proviene de un estado persistido, el error desaparece.

Regla transferible

Todo proceso de subida debe exponer un punto de control idempotente que el agente pueda consultar en cualquier momento para saber cuántos bytes ya fueron recibidos y, si falta algo, reanudar desde ese punto.

Esta regla se aplica tanto a Laravel como a Next.js. El resto del artículo muestra cómo implementarla con Redis como store temporal, Tailwind CSS para la UI y TypeScript para tipar la lógica del cliente.

Arquitectura mínima

1. Generar un identificador de subida en Laravel

El primer paso es crear una ruta que devuelva un uploadId único y la información básica del archivo. El agente llama a esta API antes de iniciar la transferencia.


use Illuminate\Support\Str;
use Illuminate\Support\Facades\Redis;
use Illuminate\Http\Request;

Route::post('/api/upload/init', function (Request $request) {
    $request->validate([
        'filename'   => 'required|string',
        'totalBytes' => 'required|integer|min:1',
    ]);

    $uploadId = (string) Str::uuid();
    $data = [
        'filename'    => $request->filename,
        'totalBytes'  => $request->totalBytes,
        'received'    => 0,
        'expiresAt'   => now()->addHours(2)->timestamp,
    ];

    Redis::setex("upload:$uploadId", 7200, json_encode($data));

    return response()->json(['uploadId' => $uploadId]);
});

El agente guarda uploadId en su contexto y lo usa para cada chunk posterior.

2. Endpoint para recibir chunks

Laravel necesita aceptar peticiones POST con multipart/form-data. Cada petición lleva:


use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Redis;

Route::post('/api/upload/chunk', function (Request $request) {
    $request->validate([
        'uploadId' => 'required|string',
        'offset'   => 'required|integer|min:0',
        'chunk'    => 'required|file',
    ]);

    $key = "upload:{$request->uploadId}";
    $meta = json_decode(Redis::get($key), true);
    if (!$meta) {
        return response()->json(['error' => 'Upload no encontrado'], 404);
    }

    // Guardar el chunk en un archivo temporal
    $tempPath = "uploads/tmp/{$request->uploadId}";
    Storage::makeDirectory($tempPath);
    $chunkPath = "$tempPath/{$request->offset}";
    Storage::put($chunkPath, $request->file('chunk')->get());

    // Actualizar offset en Redis
    $meta['received'] = $request->offset + $request->file('chunk')->getSize();
    Redis::setex($key, 7200, json_encode($meta));

    // Si ya recibimos todo, ensamblar
    if ($meta['received'] >= $meta['totalBytes']) {
        $finalPath = "uploads/complete/{$meta['filename']}";
        $handle = fopen(Storage::path($finalPath), 'w');
        for ($i = 0; $i < $meta['received']; $i += $request->file('chunk')->getSize()) {
            $part = Storage::get("$tempPath/{$i}");
            fwrite($handle, $part);
        }
        fclose($handle);
        Storage::deleteDirectory($tempPath);
        Redis::del($key);
        return response()->json(['status' => 'completed']);
    }

    return response()->json(['received' => $meta['received']]);
});

El agente solo necesita saber cuántos bytes fueron aceptados; el backend se encarga de ensamblar cuando la suma coincide con totalBytes.

3. Consultar el estado desde el agente (Redis)

En muchos casos el agente no está en la misma red que Laravel, pero puede hacer una petición HTTP a un endpoint de “status”.


Route::get('/api/upload/status/{uploadId}', function ($uploadId) {
    $key = "upload:$uploadId";
    $meta = Redis::get($key);
    if (!$meta) {
        return response()->json(['error' => 'No existe'], 404);
    }
    return response()->json(json_decode($meta, true));
});

El agente llama a este endpoint antes de cada intento de envío. Si la respuesta indica que ya se recibieron 5 MB, el cliente envía el siguiente chunk a partir de ese offset.

4. Frontend en Next.js: UI resumible con Tailwind

El componente Uploader.tsx muestra un input de tipo file, un botón de “Reanudar” y una barra de progreso.


import { useState, useRef } from 'react';
import axios from 'axios';

export default function Uploader() {
  const [progress, setProgress] = useState(0);
  const [uploadId, setUploadId] = useState<string | null>(null);
  const fileRef = useRef<File | null>(null);
  const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MiB

  const startUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    fileRef.current = file;

    // 1️⃣ Obtener uploadId
    const init = await axios.post('/api/upload/init', {
      filename: file.name,
      totalBytes: file.size,
    });
    setUploadId(init.data.uploadId);

    // 2️⃣ Enviar chunks
    await sendChunks(init.data.uploadId, 0);
  };

  const sendChunks = async (id: string, start: number) => {
    const file = fileRef.current!;
    const end = Math.min(start + CHUNK_SIZE, file.size);
    const blob = file.slice(start, end);
    const form = new FormData();
    form.append('uploadId', id);
    form.append('offset', start.toString());
    form.append('chunk', blob);

    const resp = await axios.post('/api/upload/chunk', form, {
      headers: { 'Content-Type': 'multipart/form-data' },
    });
    const received = resp.data.received;
    setProgress(Math.round((received / file.size) * 100));

    if (received < file.size) {
      // Recursivo hasta completar
      await sendChunks(id, received);
    } else {
      alert('Subida completa');
    }
  };

  const resumeUpload = async () => {
    if (!uploadId) return;
    const status = await axios.get(`/api/upload/status/${uploadId}`);
    const received = status.data.received;
    await sendChunks(uploadId, received);
  };

  return (
    <div className="max-w-lg mx-auto p-4">
      <input
        type="file"
        className="block w-full text-sm text-gray-500
                   file:mr-4 file:py-2 file:px-4
                   file:rounded-full file:border-0
                   file:text-sm file:font-semibold
                   file:bg-indigo-50 file:text-indigo-700
                   hover:file:bg-indigo-100"
        onChange={startUpload}
      />
      {uploadId && (
        <button
          onClick={resumeUpload}
          className="mt-2 px-4 py-2 bg-yellow-500 text-white rounded"
        >
          Reanudar
        </button>
      )}
      <div className="mt-4 w-full bg-gray-200 rounded-full h-2.5">
        <div
          className="bg-indigo-600 h-2.5 rounded-full"
          style={{ width: `${progress}%` }}
        ></div>
      </div>
      <p className="mt-2 text-sm text-gray-600">{progress}%</p>
    </div>
  );
}

El botón “Reanudar” solo aparece si ya existe un uploadId. Cuando el agente vuelve a lanzar la UI (por ejemplo, después de un reinicio del navegador), puede leer uploadId de localStorage y llamar a resumeUpload automáticamente.

5. Manejo de expiración y limpieza

Redis mantiene la información solo por 2 horas (configurable). Si el agente no reanuda antes de ese tiempo, el backend elimina la carpeta temporal y el agente debe iniciar una nueva subida. Añade un listener en Laravel que escuche eventos de expiración:


Redis::expire('upload:' . $uploadId, 7200); // refresca cada chunk

En el cliente, antes de intentar resumeUpload, verifica que el estado no sea 404. Si lo es, muestra al usuario “La sesión expiró, comienza de nuevo”.

6. Seguridad básica

7. Integración con el agente de IA

El agente no necesita saber nada de HTTP ni de Redis; solo llama a dos métodos abstractos:


async function iniciarSubida(file: File): Promise<string> {
  // llama a /api/upload/init y devuelve uploadId
}
async function consultarEstado(uploadId: string): Promise<{received: number, total: number}> {
  // llama a /api/upload/status/{uploadId}
}

El flujo del agente queda así:

  1. Detecta que el usuario quiere subir un archivo.
  2. Llama a iniciarSubida y guarda uploadId en su memoria de corto plazo.
  3. Envía chunks usando la lógica del componente Uploader.
  4. Después de cada chunk, pregunta consultarEstado. Si el número de bytes recibidos coincide con el total, el agente avanza al siguiente paso del workflow.

Así, el mensaje “Subida completa” proviene de una verificación explícita del estado, no de una suposición basada en la finalización del bucle de envío.

8. Pruebas rápidas

Para validar que la regla de idempotencia funciona, ejecuta estos pasos manuales:

Conclusión práctica

Aplicar la regla de punto de control idempotente permite que cualquier agente que solo sabe “subida completa” obtenga una respuesta fiable sin necesidad de orquestadores complejos. Con Laravel como receptor, Redis como store de estado y Next.js + TypeScript como cliente, puedes implementar subidas resumibles en menos de 200 líneas de código y con una UI ligera de Tailwind.

Si tu próximo caso de uso implica procesamiento en tiempo real (por ejemplo, transcodificación de video mientras llega), extiende el mismo patrón: cada chunk actualizado dispara un evento en Redis Streams y tu worker puede consumirlo sin esperar a que la subida termine.