Una migración cambia el esquema: tablas, columnas, índices. Debe terminar en segundos, porque el despliegue espera por ella, y una migración interrumpida a mitad de ejecución se reintenta desde el principio. El trabajo de datos que puede tocar muchas filas o archivos no se ejecuta dentro de migrate; se ejecuta en la cola, despachado desde el mismo archivo de migración.
El trabajo pesado de datos es cualquier tarea cuyo coste crece con los datos: un bucle sobre filas que realiza el trabajo en sí, copiar, mover o borrar archivos en un disco o en S3, calcular hashes de archivos, o llamar a un servicio externo. El trabajo acotado se queda en línea: una lista fija de claves o ids, como crear un permiso o corregir un texto, y una única sentencia basada en conjuntos (un update(), delete() o insert … select), que es un solo viaje de ida y vuelta sin importar cuántas filas afecte, salvo que la tabla sea tan grande que la propia sentencia mantenga bloqueos durante mucho tiempo.
La forma aprobada es un solo archivo: la migración realiza el cambio de esquema, selecciona solo los ids de las filas a tratar y despacha un closure static function por cada bloque. No crees una clase Job para una migración.
- Protege la parte del esquema para que una ejecución repetida no haga daño (
Schema::hasColumn()), y después recorre los ids con chunkById(), seleccionando solo id. Usa entre 20 y 50 ids por bloque para trabajo con archivos, S3 o servicios externos, y unos cientos para SQL puro; el número es el que permita que un bloque termine bien por debajo del timeout del worker en el entorno de destino.
- El closure debe ser
static y capturar solo valores simples (use ($table, $ids)). Un closure que usa $this, o self:: o static::, no puede ejecutarse: una migración es una clase anónima, así que $this no se puede serializar (Serialization of 'class@anonymous' is not allowed) y self:: se resuelve a un nombre de clase que el worker no conoce. Por tanto, toda la lógica vive dentro del closure, y los métodos auxiliares de la migración no pueden llamarse desde él.
- El closure viaja en la cola con su propio código y con los nombres de clase que importa la migración, así que sigue funcionando aunque el archivo de migración se haya borrado. Cada clase que use debe seguir existiendo cuando se ejecute; si un cambio posterior podría eliminar una columna o clase que lee, usa
DB::table() en lugar de un modelo.
- Haz que cada closure sea idempotente: vuelve a comprobar lo ya hecho antes de actuar, nunca dupliques una fila ni un archivo, y borra un original solo después de verificar su copia. Así puede reintentarse, interrumpirse o ejecutarse dos veces con el mismo resultado. Registra el progreso para distinguir una migración atascada de una lenta. No reanudes desde un cursor de último id almacenado, y no hagas que un closure se vuelva a despachar a sí mismo: la migración ya encoló cada bloque.
- El despacho no debe dejar sin recursos al resto de la cola: reparte los bloques con
delay() o encadénalos en lugar de encolar miles a la vez.
- Despacha en una conexión de cola real: con
QUEUE_CONNECTION=sync los closures se ejecutan en línea dentro de migrate. Añade ->afterCommit() para que, cuando la migración se ejecute en una transacción, ningún closure empiece antes de que el cambio de esquema sea visible.
- El despliegue no espera a este trabajo, y los datos no están migrados cuando termina el despliegue. Tiene que haber un worker de cola en ejecución:
php artisan queue:restart solo indica a los workers activos que se recarguen, no inicia ninguno.
- Una migración de este tipo puede borrarse una vez que todos los entornos la hayan aplicado y la tabla
jobs no contenga ninguno de sus closures.
El analizador marca, dentro de una migración, los bucles sobre filas que realizan el trabajo en sí, las llamadas a Storage:: y ->disk(), los hashes de archivos, las llamadas a Http:: y curl fuera de un closure despachado, y un closure despachado que no es static o que usa $this, self:: o static::. No detecta todos los tipos de trabajo pesado (un each sobre una consulta, por ejemplo), así que una ejecución limpia no demuestra que no quede ninguno.
// Correcto — la migración cambia el esquema y despacha closures static
public function up(): void
{
if (! Schema::hasColumn('product_files', 'checksum')) {
Schema::table('product_files', function (Blueprint $table) {
$table->string('checksum')->nullable();
});
}
DB::table('product_files')->whereNull('checksum')->select('id')->chunkById(30, function ($rows) {
$ids = $rows->pluck('id')->all();
dispatch(static function () use ($ids) {
foreach ($ids as $id) {
// volver a comprobar, copiar, verificar y luego borrar el original
}
})->afterCommit();
});
}
// Incorrecto — el despliegue espera a cada archivo
public function up(): void
{
ProductFile::query()->chunkById(100, function ($files) {
foreach ($files as $file) {
Storage::disk('s3')->copy($file->path, $file->media_path);
}
});
}