Cómo migramos nuestra aplicación Laravel de SQLite a MariaDB

Cómo migramos nuestra aplicación Laravel de SQLite a MariaDB

El 7 de octubre de 2026 pasamos la base de datos de este sitio web de SQLite a MariaDB. El sitio es una aplicación Laravel 13 sobre PHP 8.5, y su base de datos da servicio tanto a lo que usan los visitantes como a nuestras propias operaciones internas. Era pequeña, de alrededor de un megabyte.

Así fue el cambio: por qué lo hicimos, qué hubo que modificar antes en el código, cómo comprobamos que no se perdía nada y las dos veces que el proceso se detuvo solo.

Por qué dejamos SQLite

SQLite fue una elección sensata para este sitio, y la repetiríamos para muchas aplicaciones Laravel pequeñas. No hay un servidor de base de datos que mantener, una copia de seguridad es un archivo y, en modo WAL, los lectores no bloquean al escritor. El límite que importa está en el mismo documento: solo puede haber un escritor a la vez. Cada escritura espera a la que va delante.

Durante casi toda la vida del sitio eso no importó. Las escrituras venían de visitantes que completaban formularios unas pocas veces por semana. A lo largo de 2026 la aplicación fue sumando trabajo que escribe con su propio calendario, no solo cuando un visitante hace algo, y había más previsto. Nada fallaba y nada iba lento. Cambiamos porque veíamos hacia dónde iban las cosas, y una migración de base de datos es mucho más tranquila cuando no se hace en medio de un incidente.

Por qué MariaDB 11.8

Instalamos MariaDB 11.8, una versión LTS, desde los propios paquetes de nuestra distribución Linux. Recibe las actualizaciones de seguridad por el mismo canal que todo lo demás del servidor, y no hay un repositorio de terceros que vigilar.

Importaban dos versiones mínimas. El driver mariadb de Laravel crea una columna UUID nativa para $table->uuid(), lo que exige MariaDB 10.7 o posterior. También queríamos explicit_defaults_for_timestamp activado por defecto, como lo está desde la 10.10. Sobre eso hablamos más abajo.

La regla que nos importaba más que el número de versión era usar la misma versión en todas partes. Nuestro entorno de desarrollo local corría MariaDB 10.6, demasiado antigua para coincidir. Pasamos el desarrollo local y CI a la 11.8 para que cada test corriera sobre lo mismo que correría producción.

Si su destino es MySQL, casi todo lo que sigue se aplica igual. La principal diferencia es la columna UUID: la gramática de MySQL en Laravel la guarda como char(36).

Donde SQLite había sido permisivo

El query builder oculta casi todas las diferencias de sintaxis entre las dos bases de datos. Lo que no puede ocultar es que no coinciden en qué se considera una escritura válida.

Textos más largos que sus columnas

SQLite no hace cumplir la longitud de un varchar. Declare string('title', 180), inserte 400 caracteres y SQLite guardará los 400. MariaDB en modo estricto, que Laravel activa por defecto, rechaza la inserción con un error.

Lo encontramos en código que guarda valores tomados de las URL de llegada junto con el envío de un formulario. Esos valores estaban limitados a 500 caracteres, muy por encima del ancho de sus columnas. En MariaDB, alguien que llegara desde un enlace con una query string muy larga habría visto fallar el envío del formulario con un error de servidor. Habríamos perdido el mensaje sin saber que existía.

La corrección recorta cada valor al ancho de su columna al escribir la fila. Un campo recibió otro trato. Guarda un identificador que solo sirve si coincide exactamente, así que uno recortado es peor que ninguno, y uno demasiado largo se descarta. Añadimos tests que envían un valor de 600 caracteres y comprueban que el envío se guarda.

Columnas de timestamp que se actualizan solas

Cuando explicit_defaults_for_timestamp está desactivado, MariaDB le da a la primera columna TIMESTAMP NOT NULL de cada tabla un DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP implícito. Cualquier actualización de la fila, por el motivo que sea, pone esa columna a la hora actual.

Varias de nuestras columnas se habrían visto afectadas, entre ellas horas de caducidad y fechas de entrada en vigor, justo el tipo de valor que nunca debe moverse solo. No hay ningún error cuando ocurre. Los valores simplemente quedan mal a partir de ese momento.

El valor por defecto es ON desde MariaDB 10.10, así que la 11.8 se comporta bien tal como se instala. Aun así lo fijamos de forma explícita en la configuración del servidor, para que un cambio futuro no pueda desactivarlo sin que nadie se entere, y nuestras comprobaciones posteriores a la copia buscan cualquier cláusula ON UPDATE que no debería estar.

La zona horaria necesitaba el mismo cuidado. MariaDB convierte los valores TIMESTAMP a través de la zona horaria de la sesión al guardarlos y al leerlos, así que un servidor configurado en hora local desplazaría cada fecha guardada. La aplicación corre en UTC, y fijamos la conexión para que coincida. Las dos son opciones estándar de una conexión de base de datos en Laravel:

'timezone' => '+00:00',
'isolation_level' => 'READ COMMITTED',

Bloqueos por fila en lugar de un escritor cada vez

Las transacciones que cambian un estado importante en nuestra aplicación empiezan con una escritura con guarda: un UPDATE que incrementa una columna de versión, pero solo si la fila sigue en el estado que el código espera. Si ninguna fila coincide, otra cosa llegó antes y la transacción se detiene. Es un patrón habitual de concurrencia optimista.

En SQLite, esa primera escritura hacía más de lo que le pedíamos. SQLite inicia las transacciones en modo DEFERRED por defecto, así que una transacción no toma ningún bloqueo hasta que lo necesita, y una transacción que hasta ese momento solo ha leído puede verse adelantada por la escritura de otra conexión. Las nuestras escribían primero. Cada una tomaba el único bloqueo de escritura de la base de datos con su primera sentencia y lo mantenía hasta el commit, así que todo lo que venía después corría sin ningún otro escritor en toda la base de datos.

En InnoDB el mismo UPDATE bloquea una fila, y otras peticiones pueden cambiar cualquier otra fila mientras tanto. Revisamos las transacciones que leían un estado y luego actuaban sobre él, y encontramos dos sitios donde el código se había estado apoyando en ese bloqueo de toda la base de datos.

En uno, una transacción bloqueaba un registro padre y después comprobaba y cambiaba varios registros relacionados. Con InnoDB esas filas relacionadas no quedaban cubiertas, así que otra petición podía cambiar una de ellas entre la comprobación y la actualización. La corrección aplica la misma actualización con guarda también a las filas relacionadas, en una sola sentencia, y trata como conflicto un número de filas afectadas menor del esperado.

El otro era un deadlock. Para generar números correlativos se usaba un INSERT IGNORE que creaba la fila del contador si faltaba, seguido de un UPDATE para incrementarlo. Cuando la fila ya existe, el insert ignorado de InnoDB sigue tomando un bloqueo compartido sobre ella, y el update necesita después uno exclusivo. Dos peticiones que hacen esto en el mismo instante tienen cada una el bloqueo compartido que la otra está esperando, e InnoDB revierte una de las dos. Un único INSERT ... ON DUPLICATE KEY UPDATE, que es lo que genera upsert() de Laravel en MariaDB, toma el bloqueo exclusivo desde el principio, así que la segunda petición simplemente espera.

El nivel de aislamiento resolvió un tercer problema, más discreto. Con el REPEATABLE READ por defecto de InnoDB, todas las lecturas normales de una transacción salen de la instantánea tomada en su primera lectura. createOrFirst() de Laravel intenta un insert y, si choca con una clave única, busca la fila existente. Si otra petición confirmó esa fila después de tomarse la instantánea, la búsqueda no la ve. Con READ COMMITTED cada lectura ve los últimos datos confirmados, así que la búsqueda encuentra la fila con la que chocó.

Collation e identificadores que distinguen mayúsculas

La collation por defecto de Laravel para MariaDB es utf8mb4_unicode_ci, que compara textos sin distinguir mayúsculas ni acentos. Es lo que se quiere para cosas como las direcciones de correo. No sirve para los identificadores que emiten servicios externos, que a menudo sí distinguen mayúsculas. Con esa collation, evt_1AbC y evt_1abc son iguales, y un índice único rechazaría como duplicado un registro realmente distinto.

Por cómo generan sus identificadores la mayoría de los proveedores, una colisión es improbable, pero la corrección es una sola collation, así que cambiamos esas columnas a utf8mb4_bin. Lo hicimos en una migración nueva que no actúa en SQLite, que ya compara los textos byte a byte:

// Illustrative. SQLite already compares bytes, so only MariaDB and MySQL change.
if (in_array(DB::connection()->getDriverName(), ['mariadb', 'mysql'], true)) {
    Schema::table('external_references', function (Blueprint $table) {
        $table->string('external_id', 64)->collation('utf8mb4_bin')->change();
    });
}

Esa comprobación del driver es como hicimos cada corrección de esquema en este proyecto. Nunca editamos una migración que ya se hubiera ejecutado. Cada corrección fue a una migración nueva, y esas migraciones llegaron a producción mientras todavía corría sobre SQLite, donde no cambiaban nada. Producción las registró como ejecutadas. Así, cuando copiamos la tabla migrations, coincidía exactamente con el código, y el esquema que construimos en MariaDB con los mismos archivos ya incluía todas las correcciones.

Los tests corrían sobre la base de datos equivocada

Algunas de nuestras tablas hacen cumplir sus reglas en la propia base de datos, con restricciones CHECK y triggers. SQLite y MariaDB los expresan de forma distinta, así que la migración que los crea tiene una rama para cada driver. Toda nuestra suite de tests corría sobre SQLite en memoria, lo que significaba que la rama de MariaDB no se había ejecutado nunca.

Todos los problemas descritos arriba también habrían pasado una ejecución de tests sobre SQLite. Así que la suite pasó a MariaDB, en local y en CI, y la ejecución sobre MariaDB es la que tiene que pasar. Mantuvimos de momento una ejecución sobre SQLite junto a ella, porque SQLite era el destino del rollback y tenía que seguir funcionando.

Copias de seguridad y cambios de esquema

Nuestro comando de copias de seguridad solo conocía SQLite y rechazaba cualquier otra cosa. Después del cambio eso habría significado quedarnos sin copias, así que aprendió MariaDB. Usa mariadb-dump --single-transaction, que lee las tablas InnoDB desde una única instantánea consistente sin bloquear el sitio mientras dura el volcado.

Una copia de seguridad no está demostrada hasta que se ha restaurado, así que restaurar una y compararla con los datos reales forma parte de la rutina.

MariaDB también cambió cómo gestionamos las migraciones de esquema durante el deploy. A diferencia de SQLite, MariaDB no permite revertir la mayoría de los cambios de esquema dentro de una transacción. Si una migración falla a mitad, los cambios que ya se aplicaron pueden quedarse en su sitio.

Por eso actualizamos nuestro proceso de deploy para que haga una copia de seguridad de la base de datos siempre que haya migraciones pendientes. Así tenemos un punto de recuperación si algo sale mal.

Aun así, una copia de seguridad no hace reversible una migración. Restaurarla devuelve toda la base de datos al momento en que se hizo la copia, y lo que se escribió después se pierde, como un formulario que alguien envió un minuto después del deploy. Tratamos una restauración como el último recurso. La verdadera protección es escribir migraciones seguras de ejecutar sobre datos en uso y saber cómo nos recuperaríamos antes de empezar un cambio de esquema.

La copia de los datos

Existen herramientas que convierten un volcado de SQLite a sintaxis de MySQL. No usamos ninguna. El esquema que queríamos en MariaDB era el que construyen allí nuestras migraciones, con la versión de MariaDB de los triggers, las collations binarias y las columnas UUID nativas. Un volcado convertido da el esquema de SQLite traducido a otro dialecto, que no es lo mismo.

Así que ejecutamos las migraciones de Laravel contra una base de datos MariaDB vacía y después copiamos solo las filas, con tres comandos artisan que escribimos para el trabajo.

El primero es una verificación previa. Lee el origen e informa de todo lo que haría fallar la copia o alteraría los datos: textos más largos que su columna de destino, valores que chocarían en un índice único con la nueva collation, JSON no válido, timestamps que no se pueden interpretar, decimales con más cifras de las que admite su columna y un historial de migraciones que no coincide con el código. Imprime nombres de tablas, nombres de columnas y recuentos, nunca un valor de una fila, así que su salida se puede pegar en cualquier sitio.

El segundo hace la copia. No se ejecuta si la verificación previa falla, y no escribe en la base de datos sobre la que corre el sitio. Inserta las tablas padre antes que las hijas, para que los triggers que buscan una fila padre la encuentren. Conserva todos los identificadores y deja cada contador autoincremental por encima del identificador más alto que SQLite llegó a emitir, para que los identificadores de filas borradas no se reutilicen nunca.

El tercero es la prueba. Reduce cada fila de ambos lados a una forma canónica, calcula su hash y compara los resultados tabla por tabla, junto con los contadores autoincrementales, las claves foráneas, los índices únicos, las reglas de la propia base de datos y la configuración del servidor de la que depende el esquema. Cualquier diferencia termina con un código distinto de cero, y el cambio no podía seguir adelante con un código distinto de cero.

La herramienta funciona en ambos sentidos, de SQLite a MariaDB y de MariaDB de vuelta a una base de datos SQLite nueva. Ese segundo sentido era nuestro rollback.

Lo que aprendimos al construirla

Compare en el código de la aplicación, no en SQL. Con utf8mb4_unicode_ci, MariaDB ignora los espacios finales al comparar textos, así que 'abc' y 'abc ' son iguales. Una comparación hecha en SQL daría por buena una fila que hubiera perdido un espacio final. La nuestra calcula el hash de cada fila en PHP, ordena esos hashes y calcula el hash de la lista ordenada. Ordenarlos hace que el resultado no dependa del orden de las filas, lo que importa porque una clave primaria de texto puede ordenarse de forma distinta con las dos collations. Un test cambia un byte de una fila y comprueba que la comparación falla.

Las columnas DATE de SQLite no guardaban fechas. El cast date de Eloquent escribe una fecha y hora completas, así que SQLite guardaba 2026-10-07 00:00:00 en columnas que MariaDB define como DATE. La copia reduce una hora de medianoche a la fecha y rechaza cualquier otra hora del día, que de otro modo se perdería en silencio.

Una base de datos recién migrada no está vacía. Una de nuestras migraciones inserta datos iniciales, así que la copia borra antes todas las filas del destino, no solo el historial de migraciones.

El ensayo y la planificación del cambio

Todo el trabajo anterior llegó a producción mientras seguía sobre SQLite y no cambió nada de lo que usaba el sitio en línea. El cambio en sí se planificó como una operación aparte.

Ensayamos la copia repetidamente en desarrollo local y después contra una instantánea reciente de los datos reales de producción. Esos datos incluyen información personal que la gente nos ha enviado, así que nunca salieron de producción para hacer pruebas, y la copia de cada ensayo se borró después.

Los privilegios de la base de datos se separaron por responsabilidad, de modo que la cuenta con la que corre la aplicación puede leer y escribir datos pero no puede cambiar el esquema.

Antes del día escribimos una lista de go/no-go: configuración del servidor confirmada, la suite completa pasando sobre las dos bases de datos, una copia de seguridad de MariaDB restaurada y verificada, una verificación previa limpia contra una instantánea de producción y el ensayo cronometrado.

El cambio tenía esta forma: poner el sitio en modo mantenimiento y pausar todo lo que escribe, hacer la instantánea de SQLite, copiar y comparar, cambiar la configuración de la aplicación a MariaDB, asegurarnos de que nada pudiera seguir escribiendo en la base de datos antigua, comprobar el sitio mientras seguía cerrado a los visitantes, hacer y verificar una copia de seguridad nueva y volver a abrir. También tuvimos en cuenta los webhooks entrantes durante el mantenimiento, incluido cómo gestionan sus proveedores los intentos de entrega fallidos.

Cómo volver atrás dependía del momento. Hasta que el sitio volviera a abrir no se habría escrito nada en MariaDB, así que volver atrás era restaurar la configuración anterior. Después de abrir, MariaDB tendría datos que la instantánea de SQLite no tenía, y volver a SQLite los perdería. A partir de ahí el plan era corregir hacia delante y, si no era posible, copiar MariaDB de vuelta a SQLite con la misma herramienta y compararla del mismo modo.

Planificamos entre 10 y 15 minutos de modo mantenimiento, la mayor parte para comprobaciones.

Lo que salió mal

Un ensayo se detuvo por la precisión de unos decimales

El primer ensayo con datos de producción se detuvo en la verificación previa.

Algunos de nuestros datos de informes tenían valores decimales con más precisión de la que admitían sus columnas en MariaDB. Son medias calculadas en otro sitio, y SQLite las había guardado con toda su precisión: un valor como 4,333333 en una columna declarada decimal(6,2). En la peor tabla, alrededor de un tercio de los valores tenían más decimales de los que admite la columna.

Comprobamos qué hace MariaDB 11.8 con eso en modo estricto. Los decimales sobrantes se redondean a la escala de la columna (un valor que queda justo en la mitad se redondea alejándose de cero), y la inserción se completa con una nota (Data truncated), no con un error. Un valor demasiado grande para la columna es otra cosa: el modo estricto lo rechaza con un error de valor fuera de rango, mientras que sin modo estricto se recortaría en silencio al máximo de la columna. Nuestros valores eran del primer tipo, así que la aplicación en línea nunca habría fallado por ellos. La pregunta era solo qué debía hacer la copia con los valores ya guardados, y la verificación previa hizo bien en detenerse y obligarnos a decidir.

No relajamos la comprobación para todo. La herramienta de copia tiene ahora una lista corta y explícita de columnas que guardan mediciones. Para esas, redondea como lo haría MariaDB y la verificación previa informa de cuántos valores redondeó. El dinero no está en esa lista. Un valor monetario con más precisión que su columna sigue deteniendo la copia, y cualquier columna decimal que se añada más adelante sigue siendo exacta salvo que alguien la incluya a propósito en la lista.

Antes de publicar ese cambio comprobamos, en modo de solo lectura contra producción, si algún valor redondeado cruzaba un umbral con el que comparan nuestros informes. Ninguno lo hacía, y el mayor cambio fue de 0,005. El siguiente ensayo pasó, con 69 valores redondeados y todas las tablas coincidiendo.

El primer cambio se revirtió solo

Hicimos el cambio con un script que lo revertía todo si fallaba cualquier paso. En el primer intento pasaron todos los pasos de base de datos. La copia y la comparación pasaron, la copia de seguridad nueva se restauró y verificó, y las páginas que comprobamos en modo mantenimiento cargaron todas. Entonces falló la propia prueba de humo del script, y se ejecutó el rollback.

La línea que falló elegía una página para probarla leyendo el sitemap con un pipeline que usaba head para quedarse con la primera coincidencia. Esta es una versión simplificada, no el script en sí:

set -euo pipefail
url=$(curl -s "$SITE/sitemap.xml" | grep -o '<loc>[^<]*/blog/[^<]*' | head -n 1)

head termina en cuanto tiene su única línea. Si grep sigue escribiendo en ese momento, SIGPIPE lo termina y sale con el código 141. Sin pipefail, el código del pipeline sería el de su último comando, que terminó bien. Con él, un código distinto de cero en cualquier punto hace fallar todo el pipeline, set -e detiene el script y, en nuestro caso, se ejecutó el rollback.

La base de datos estaba bien. El fallo estaba en el script de validación que la comprobaba. El rollback devolvió la configuración anterior y el archivo SQLite y volvió a abrir el sitio sobre SQLite, tal como estaba diseñado, así que nuestro rollback previo a la reapertura quedó probado de verdad.

Si escribe scripts con pipefail, asegúrese de que ningún comando de un pipeline deje de leer mientras otro anterior sigue escribiendo. sed -n 1p y awk 'NR == 1' leen toda su entrada, así que cualquiera de los dos puede sustituir a head aquí. Cambiar head por grep -m 1 no lo arregla, porque entonces es grep el que termina antes y el comando que lo alimenta puede caer por la misma señal. Guardar la salida en una variable o un archivo y filtrar después evita el problema por completo.

El segundo intento

Lo ejecutamos otra vez dos minutos después y pasó. El modo mantenimiento duró alrededor de un minuto y medio, muy por debajo de los 10 a 15 que habíamos planificado, sobre todo porque la base de datos era pequeña y todas las comprobaciones estaban automatizadas. Copiar y comparar tardó unos segundos, y todas las tablas coincidieron.

Antes de volver a abrir confirmamos que la aplicación podía escribir, que una copia de seguridad nueva de MariaDB se restauraba sin problemas y que los logs no mostraban errores nuevos. Después de abrir, nuestra suite de tests end-to-end pasó los 53 tests contra el sitio en línea, y el servidor web no registró ningún error de servidor.

Cómo salió

El sitio corre sobre MariaDB desde el 7 de octubre. SQLite fue la base de datos adecuada durante mucho tiempo, y hoy la seguiríamos eligiendo para una aplicación Laravel pequeña. Lo que cambió fue cuánto de la aplicación escribe a la vez, y ahí MariaDB encaja mejor.

La mayor parte del trabajo consistió en encontrar los sitios donde el código se había apoyado sin decirlo en el comportamiento de SQLite: textos que nunca recortaba, un bloqueo que cubría más de lo que habíamos pedido, índices únicos que habrían empezado a ignorar las mayúsculas. Revisar el código frente al comportamiento de MariaDB encontró casi todos antes de mover un solo dato, y pasar la suite de tests a MariaDB hizo que las correcciones se probaran en cada ejecución desde entonces. Construir la herramienta de copia y ensayar con datos de producción detectó las diferencias de datos restantes. La comparación final coincidió en todas las tablas, con las columnas de mediciones comprobadas tras el mismo redondeo que habíamos aprobado de antemano.

La única sorpresa del día vino de nuestro propio script de validación, no de la base de datos. El rollback construido para un fallo de base de datos lo resolvió sin complicaciones, y era la primera vez que se ejecutaba contra producción. Si está planificando un cambio parecido para una aplicación Laravel, es el tipo de trabajo que hacemos dentro de nuestro servicio de desarrollo con Laravel y del mantenimiento continuo de aplicaciones.

Preguntas frecuentes

¿Puedo convertir sin más un volcado de SQLite a la sintaxis de MySQL o MariaDB?

Para una base de datos desechable, quizá. Para una aplicación con historial de migraciones es arriesgado: el esquema convertido no coincidirá con el que sus migraciones construirían en MariaDB, los triggers y las restricciones CHECK se escriben de forma distinta en cada motor, y nada le dice si cada fila llegó intacta. Nosotros construimos el esquema con las migraciones de Laravel en MariaDB y después copiamos solo las filas, con una comparación al final.

¿Cuánto tarda un cambio de SQLite a MariaDB?

Depende de cuántos datos tenga y de cuánto compruebe con el sitio fuera de línea. Nuestra base de datos ocupaba alrededor de un megabyte, así que copiar y comparar tardó segundos. Planificamos entre 10 y 15 minutos de modo mantenimiento, sobre todo para pruebas de humo, y el intento que salió bien tardó alrededor de un minuto y medio.

¿El código Laravel escrito para SQLite funciona en MariaDB sin cambios?

La mayor parte del código del query builder y de Eloquent, sí. Los problemas son de comportamiento: MariaDB en modo estricto rechaza un texto más largo que su columna donde SQLite lo guardaba, la collation por defecto trata igual mayúsculas y minúsculas, e InnoDB bloquea filas en lugar de permitir un solo escritor a la vez. Ejecute la suite de tests completa sobre MariaDB antes del cambio, porque una ejecución sobre SQLite no detecta ninguno de esos problemas.

¿Cómo se hace el rollback de una migración de base de datos cuando el sitio ya está otra vez en línea?

No volviendo al archivo de base de datos antiguo. En cuanto la base de datos nueva ha recibido escrituras reales, a la instantánea antigua le faltan. Mantuvimos nuestra herramienta de copia funcionando en ambos sentidos, así que volver atrás habría significado copiar MariaDB a una base de datos SQLite nueva y comprobarla igual que comprobamos el paso hacia delante.

Trabaje con Emyrix

¿Está trabajando en algo parecido?

Construimos y mantenemos aplicaciones Laravel: portales de clientes, herramientas internas, capas de integración y bases de código heredadas que nadie quiere tocar. Si tiene un problema de rendimiento o de seguridad que prefiere no dejar pasar, cuéntenos qué ocurre y le diremos qué opinamos.