Cuando una migración añade un índice a una tabla con millones de filas, la duda es si las escrituras se van a quedar esperando mientras MySQL lo crea. Laravel 13.33, del 22 de septiembre de 2026, añade el modificador inplace(), que junto a lock('none') le pide a MySQL que haga el cambio sin bloquear la tabla y que, si no puede, falle en lugar de bloquearla.
La función nace de una caída parcial en producción: una migración bloqueó una tabla grande al añadir el índice de una clave foránea, como cuenta su autor en la PR #61602. En esta entrada vamos a medir qué bloquea las escrituras y qué no en una tabla de 2 millones de filas. Un índice normal no las bloquea. Una clave foránea dejó una escritura parada hasta 15 s, y con una transacción abierta la espera llegó a 18 s, aunque la migración pedía lock('none'). Todo está probado, en el momento de crear esta entrada, con Laravel 13.36, PHP 8.5 y MySQL 8.4 en Laravel Sail.
La prueba: 2 millones de pedidos
Vamos a usar una tabla orders con 2 millones de filas, unos 450 MB de datos. Crea su migración con php artisan make:migration create_orders_table y cambia sus métodos por los siguientes:
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('customer_id');
$table->string('status', 20);
$table->string('reference', 40);
$table->decimal('total', 10, 2);
$table->string('notes');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
Su columna customer_id no tiene índice ni clave foránea: los añadiremos más adelante. Los clientes van en la tabla customers, con esta migración (php artisan make:migration create_customers_table):
public function up(): void
{
Schema::create('customers', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('customers');
}
Las 2 tablas las llenamos con un seeder y un único INSERT ... SELECT en MySQL. Para saber si una migración bloquea la tabla, necesitamos algo que escriba en ella mientras tanto. Crea un comando con php artisan make:command WriteOrders y cambia el contenido de app/Console/Commands/WriteOrders.php por el siguiente:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
#[Signature('orders:write {--seconds=60}')]
#[Description('Inserta un pedido cada 10 ms y mide cuánto tarda cada escritura')]
class WriteOrders extends Command
{
public function handle(): void
{
$times = [];
$until = microtime(true) + (int) $this->option('seconds');
while (microtime(true) < $until) {
$start = hrtime(true);
DB::table('orders')->insert([
'customer_id' => random_int(1, 10000),
'status' => 'pending',
'reference' => Str::uuid(),
'total' => 19.90,
'notes' => 'Escrito durante la migración',
'created_at' => now(),
'updated_at' => now(),
]);
$times[] = (hrtime(true) - $start) / 1e6;
usleep(10_000);
}
sort($times);
$this->table(['Escrituras', 'Mediana (ms)', 'Máxima (ms)', 'Más de 1 s'], [[
count($times),
sprintf('%.1f', $times[intdiv(count($times), 2)]),
sprintf('%.1f', end($times)),
count(array_filter($times, fn (float $ms) => $ms > 1000)),
]]);
}
}
Si te fijas, el comando inserta un pedido cada 10 ms durante los segundos que le digas y mide cuánto tarda cada inserción. Al terminar enseña la mediana, la escritura más lenta y cuántas han pasado de 1 s. La más lenta es la que nos dice cuánto tiempo ha estado bloqueada la tabla.
En cada prueba lanzamos el comando, esperamos 3 s y ejecutamos la migración. Sin migración de por medio, cada inserción tarda unos 4 ms.
Un índice normal no bloquea las escrituras
Vamos a crear la migración que añade un índice a la columna status:
php artisan make:migration add_status_index_to_orders_table --table=orders
Abre la migración que se acaba de crear y cambia sus métodos up() y down() por los siguientes:
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->index('status');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropIndex(['status']);
});
}
MySQL crea el índice en unos 3 s y, mientras tanto, la escritura más lenta tarda 30 ms. Es lo que dice la documentación de MySQL: en una tabla InnoDB, un índice secundario se añade sobre la propia tabla (el algoritmo INPLACE), sin reconstruir la tabla y permitiendo escrituras. MySQL solo bloquea la tabla un instante, al principio y al final, para cambiar su definición.
Qué añaden inplace() y lock('none')
Con un índice normal, inplace() no cambia el resultado, porque MySQL ya usa ese algoritmo. Lo que aporta es una garantía:
$table->index('status')->inplace()->lock('none');
Laravel añade al final de la sentencia , algorithm=inplace, lock=none, como puedes ver con php artisan migrate --pretend. Sin esas opciones, MySQL elige el algoritmo, y si la operación no se puede hacer sobre la propia tabla, usa COPY: crea una tabla nueva, copia en ella todas las filas y, mientras tanto, no deja escribir. Con ellas, si MySQL no puede cumplirlas, la migración falla al momento y la tabla no se toca. En nuestra prueba, el índice tarda entre 3 y 6 s según la ejecución y la escritura más lenta, 25 ms: lo mismo que sin el modificador.
lock() ya existía para índices y columnas. Lo nuevo de Laravel 13.33 es inplace(), que funciona con los índices y con las claves foráneas.
La clave foránea sí bloquea la tabla
El caso de la PR era otro: añadir una clave foránea a una columna que ya existe. Vamos a probarlo con customer_id. Crea otra migración con php artisan make:migration add_customer_foreign_to_orders_table --table=orders y cambia sus métodos por los siguientes:
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->foreign('customer_id')->references('id')->on('customers');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropForeign(['customer_id']);
$table->dropIndex('orders_customer_id_foreign');
});
}
Al crear la clave foránea, MySQL añade también un índice en customer_id con el mismo nombre, y por eso down() borra los 2.
Esta vez la migración tarda entre 10 y 17 s según la ejecución, y el comando de escritura enseña esto:
+------------+--------------+-------------+------------+
| Escrituras | Mediana (ms) | Máxima (ms) | Más de 1 s |
+------------+--------------+-------------+------------+
| 1250 | 3.8 | 14913.7 | 1 |
+------------+--------------+-------------+------------+
Una escritura se ha quedado esperando casi 15 s, prácticamente toda la migración. Como el comando escribe de una en una, solo una ha pasado de 1 s, pero en una aplicación con tráfico todas las peticiones que escriben en orders esperarían ese tiempo. MySQL solo puede añadir una clave foránea con INPLACE si las comprobaciones de claves foráneas (foreign_key_checks) están desactivadas. Con ellas activadas, que es lo normal, usa COPY.
Si añades inplace()->lock('none') a la clave foránea, la migración falla al momento, sin tocar la tabla:
2026_10_13_100000_add_customer_foreign_to_orders_table ......... 2.23ms FAIL
SQLSTATE[0A000]: Feature not supported: 1846 ALGORITHM=INPLACE is not supported. Reason: Adding foreign keys needs foreign_key_checks=OFF. Try ALGORITHM=COPY. (Connection: mysql, Host: mysql, Port: 3306, Database: laravel, SQL: alter table `orders` add constraint `orders_customer_id_foreign` foreign key (`customer_id`) references `customers` (`id`), algorithm=inplace, lock=none)
Crear la clave foránea sin bloquear
Para que MySQL pueda usar INPLACE, tenemos que desactivar las comprobaciones durante la migración. Laravel lo hace con Schema::withoutForeignKeyConstraints(). En la migración de la clave foránea, cambia el método up() por el siguiente; down() se queda igual:
public function up(): void
{
Schema::withoutForeignKeyConstraints(function () {
Schema::table('orders', function (Blueprint $table) {
$table->foreign('customer_id')->references('id')->on('customers')->inplace()->lock('none');
});
});
}
La migración ejecuta SET FOREIGN_KEY_CHECKS=0, crea la clave y vuelve a activar las comprobaciones. Tarda unos 2,4 s y la escritura más lenta, 45 ms.
A cambio, MySQL no comprueba las filas que ya existen. Lo hemos probado con un pedido cuyo customer_id no existe en customers. Con COPY, la migración falla con el error 1452 después de 15 s copiando la tabla. Sin comprobaciones, la clave se crea igual y el pedido huérfano se queda en la tabla. Por eso, antes de lanzar esta migración, busca los huérfanos, por ejemplo con php artisan tinker:
DB::table('orders')
->leftJoin('customers', 'customers.id', '=', 'orders.customer_id')
->whereNull('customers.id')
->count();
Si devuelve 0, puedes crear la clave sin comprobaciones. Si no, decide antes qué hacer con esos pedidos.
La transacción que bloquea aunque uses lock('none')
Hay un bloqueo que lock('none') no evita. Para cambiar la definición de la tabla, MySQL necesita un bloqueo exclusivo de sus metadatos, aunque sea un instante, y no lo consigue mientras siga abierta una transacción que haya usado la tabla. Mientras la migración espera ese bloqueo, las consultas que llegan detrás esperan con ella, como explica la documentación de MySQL.
Para probarlo, mientras el comando escribe, abrimos una transacción que lee un pedido y tarda 20 s en confirmarse, como haría un job que procesa datos dentro de DB::transaction(). Con el índice y inplace()->lock('none'), la migración tarda 24 s y una escritura se queda esperando 18 s. Esto es lo que enseña SHOW PROCESSLIST en una de estas esperas:
Id User Host db Command Time State Info
100 sail 172.30.0.3:37638 laravel Prepare 4 Waiting for table metadata lock insert into `orders` (`customer_id`, `status`, `reference`, `total`, `notes`, `created_at`, `updated
101 sail 172.30.0.3:52406 laravel Sleep 6 NULL
102 sail 172.30.0.3:52408 laravel Execute 4 Waiting for table metadata lock alter table `orders` add index `orders_status_index`(`status`), algorithm=inplace, lock=none
La migración y la inserción esperan con el estado Waiting for table metadata lock. La transacción que las bloquea no está ejecutando nada: es la conexión en Sleep, abierta desde antes que las otras 2.
Limitar la espera con lock_wait_timeout
Lo que dura esa espera lo marca la variable lock_wait_timeout, que por defecto es de un año (31.536.000 s). Si la bajas en la migración, MySQL abandona el cambio al pasar ese tiempo y las escrituras siguen. Abre la migración del índice y cambia el método up() por el siguiente, con el use de Illuminate\Support\Facades\DB:
public function up(): void
{
DB::statement('SET SESSION lock_wait_timeout = 2');
Schema::table('orders', function (Blueprint $table) {
$table->index('status')->inplace()->lock('none');
});
}
Con la misma transacción abierta, la migración falla a los 2 s con el error 1205 Lock wait timeout exceeded; try restarting transaction y la escritura más lenta tarda 2 s. Como la migración no ha terminado, Laravel no la apunta en la tabla migrations, y puedes volver a lanzarla cuando la transacción termine.
A partir de aquí, en MySQL 8.4 un índice normal no te va a bloquear la tabla, y con inplace()->lock('none') te aseguras de que, si algún día no se puede crear así, la migración falle en vez de parar las escrituras. Las claves foráneas de las tablas grandes, créalas sin comprobaciones y después de buscar los huérfanos. Y un lock_wait_timeout de pocos segundos evita que una transacción olvidada pare la aplicación entera.
Espero que te sirva. Si quieres repasar cómo se crean y se modifican las tablas, tienes la entrada Migraciones en Laravel.
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.
star Incluido en cualquier suscripción