Saltar al contenido
Volver a todos los artículos
Architecture Development WordPress 4 May 2026 · 14 min de lectura

Por qué escribí mi propio sistema de migraciones en lugar de usar dbDelta

Francisco Silva

Francisco Silva

Socio Sénior de Ingeniería WordPress.

Por qué escribí mi propio sistema de migraciones en lugar de usar dbDelta

La función integrada de WordPress dbDelta() es suficiente para plugins sencillos. En el momento en que tu esquema se vuelve no trivial —claves foráneas, ALTER TABLE, migraciones ordenadas— se convierte en un problema. Aquí está el ejecutor de migraciones ligero que escribí en 100 líneas y que resolvió el problema.

Los plugins de WordPress gestionan sus propias tablas de base de datos mediante una función llamada dbDelta(). Le proporcionas una sentencia CREATE TABLE, ella determina lo que ya existe y realiza los ajustes necesarios. ¿Añadir una columna? dbDelta la añadirá. ¿Eliminar una columna? dbDelta… normalmente no lo hace. ¿Cambiar el tipo de una columna? Quizás. ¿Añadir una clave foránea? Casi con toda seguridad no de una forma en la que puedas confiar.

Para un plugin con tres tablas y un esquema plano, dbDelta es suficiente. Para cualquier cosa seria, es una fuente de problemas.

Estoy desarrollando un plugin con alrededor de veinte tablas de dominio, relaciones de clave foránea entre la mayoría de ellas, y una hoja de ruta que incluye añadir columnas, modificar índices y renombrar elementos. Probé dbDelta para la primera migración. En la segunda, ya había escrito mi propio ejecutor.

Este artículo muestra el ejecutor —unas 100 líneas de PHP— y explica por qué existe cada parte.

Por qué dbDelta no es suficiente

Antes de mostrar lo que construí, déjame ser específico sobre dónde falla dbDelta. La documentación oficial de WordPress enumera algunas peculiaridades. Las que me afectaron:

Las claves foráneas no son fiables. dbDelta analiza tu sentencia CREATE TABLE para entender qué debería existir y, a continuación, emite sentencias ALTER TABLE según sea necesario. Sin embargo, su analizador es frágil cuando se enfrenta a la sintaxis FOREIGN KEY ... REFERENCES ... ON DELETE RESTRICT. En ejecuciones posteriores, a veces intenta recrear la clave foránea, falla porque ya existe y escribe un error vago en el registro de errores de WordPress. El comportamiento depende del entorno: funciona en MariaDB 10.6 y falla en MySQL 8.0, o viceversa.

Sin concepto de versión. dbDelta compara tu cadena de “esquema deseado” con el estado actual de la base de datos e intenta convergerlos. Si cambias el esquema deseado entre despliegues, puede que detecte el cambio o puede que no. No existe ningún registro de qué migraciones se han ejecutado. No hay forma de escribir una migración que rellene datos y se ejecute una sola vez.

El orden es implícito. Si la migración B depende de que la migración A se haya ejecutado previamente, dbDelta no tiene ningún concepto de «primero ejecutar A, luego B». O las agrupas juntas (y pierdes granularidad) o confías en que tu hook de activación las ejecute en el orden del código fuente (lo cual es frágil).

Sin migraciones hacia abajo. Si añades una columna y luego necesitas eliminarla, dbDelta no la eliminará. Tienes que escribir tú mismo el ALTER TABLE DROP COLUMN, recordar eliminarlo de la instrucción CREATE TABLE y esperar que nadie esté ejecutando la versión antigua.

Para mi proyecto, necesitaba:

  • Una lista ordenada de migraciones, cada una con una versión única
  • Un registro de qué migraciones ya se han ejecutado
  • ALTER TABLE real para añadir claves foráneas tras la creación de las tablas
  • La capacidad de rellenar o transformar datos dentro de una migración
  • Tests de integración que puedan reconstruir el esquema desde cero de forma determinista

Así que lo escribí yo mismo.

El ejecutor de migraciones

Aquí está la clase Migrations completa:

declare(strict_types=1);

namespace AvaliarDatabase;

use wpdb;

final class Migrations
{
    private wpdb $db;
    private string $registryTable;

    public function __construct()
    {
        global $wpdb;
        $this->db = $wpdb;
        $this->registryTable = $wpdb->prefix . 'avaliar_migrations';
    }

    /**
     * Ensure the migration registry table exists. Safe to call repeatedly.
     */
    public function ensureRegistry(): void
    {
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        $charset = $this->db->get_charset_collate();
        dbDelta(
            "CREATE TABLE {$this->registryTable} (
                version BIGINT UNSIGNED NOT NULL,
                name VARCHAR(191) NOT NULL,
                applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                PRIMARY KEY (version)
            ) {$charset};"
        );
    }

    /**
     * Run all pending migrations in version order.
     */
    public function runPending(): void
    {
        $this->ensureRegistry();
        $applied = $this->appliedVersions();
        foreach ($this->discover() as $migration) {
            if (in_array($migration->version(), $applied, true)) {
                continue;
            }
            $migration->up();
            $this->markApplied($migration);
        }
    }

    /**
     * @return Migration[]
     */
    public function discover(): array
    {
        $dir = __DIR__ . '/migrations';
        if (!is_dir($dir)) {
            return [];
        }
        $files = glob($dir . '/*.php') ?: [];
        sort($files);

        $migrations = [];
        foreach ($files as $file) {
            /** @var Migration $instance */
            $instance = require $file;
            if (!$instance instanceof Migration) {
                continue;
            }
            $migrations[$instance->version()] = $instance;
        }
        ksort($migrations);
        return array_values($migrations);
    }

    /**
     * @return int[]
     */
    private function appliedVersions(): array
    {
        $rows = $this->db->get_col("SELECT version FROM {$this->registryTable}");
        return array_map('intval', $rows ?: []);
    }

    private function markApplied(Migration $m): void
    {
        $this->db->insert(
            $this->registryTable,
            [
                'version' => $m->version(),
                'name' => $m->name(),
                'applied_at' => current_time('mysql', true),
            ],
            ['%d', '%s', '%s']
        );
    }
}

Eso es todo. Unas 80 líneas. Veamos en detalle las decisiones de diseño.

La interfaz de migración

Cada migración es una clase que implementa esta pequeña interfaz:

namespace AvaliarDatabase;

interface Migration
{
    public function version(): int;
    public function name(): string;
    public function up(): void;
}

Un archivo de migración tiene este aspecto:

// includes/Database/migrations/001_initial_schema.php

declare(strict_types=1);

use AvaliarDatabaseMigration;

return new class implements Migration {
    public function version(): int
    {
        return 1;
    }

    public function name(): string
    {
        return 'initial_schema';
    }

    public function up(): void
    {
        global $wpdb;
        $charset = $wpdb->get_charset_collate();

        $wpdb->query("
            CREATE TABLE {$wpdb->prefix}avaliar_schools (
                id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                school_id BIGINT UNSIGNED NOT NULL,
                name VARCHAR(191) NOT NULL,
                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                PRIMARY KEY (id),
                KEY idx_school (school_id)
            ) {$charset};
        ");

        // ... more CREATE TABLE statements
    }
};

Hay que destacar tres aspectos:

La migración es una clase anónima devuelta. Esto evita contaminar cualquier espacio de nombres, otorga a cada archivo un aislamiento completo y permite múltiples migraciones en el mismo proyecto sin colisiones de nombres.

Solo up() — sin down(). Tomé la decisión deliberada de no admitir migraciones reversibles. En el mundo real, los datos de producción son los datos de producción. Las reversiones se realizan escribiendo una nueva migración de «corrección», no ejecutando down() con la esperanza de que la base de datos vuelva a un estado anterior. La simplicidad aporta claridad.

Llamadas directas a $wpdb->query(), no a dbDelta(). Utilizo sentencias CREATE TABLE y ALTER TABLE en crudo. Se ejecutan una sola vez, son predecibles y wpdb->query devuelve false en caso de error (algo que verifico en el código de producción).

Por qué el número de versión reside en el contenido del archivo y no en el nombre del archivo

Puede que hayas observado que el archivo se llama 001_initial_schema.php pero la clase devuelve version() === 1. ¿Por qué ambos?

El prefijo del nombre de archivo (001_) sirve para ordenar. glob() devuelve los archivos en orden alfabético, por lo que 001_* va antes que 002_*, que a su vez va antes que 010_*. Esto hace que el orden sea evidente para cualquiera que consulte el directorio.

El entero en version() es la versión canónica. Es el valor que se almacena en la tabla de registro. El nombre del archivo podría cambiar (por ejemplo, si alguien renombra 001_initial_schema.php a 001_create_initial_tables.php) y el registro no se rompería — la versión seguiría siendo 1.

Esta separación importa porque los nombres de archivo pueden variar. Las versiones no pueden.

Tabla de registro: la fuente de verdad

La tabla de registro es una tabla única con tres columnas:

CREATE TABLE wp_avaliar_migrations (
    version BIGINT UNSIGNED NOT NULL,
    name VARCHAR(191) NOT NULL,
    applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (version)
);

Tras ejecutarlo, el resultado es el siguiente:

versionnameapplied_at
1initial_schema2026-04-18 14:32:01
2add_config_overrides2026-04-18 14:32:02
3class_owner2026-04-19 09:15:33

Cuando se llama a runPending(), consulta esta tabla para obtener la lista de versiones aplicadas y, a continuación, itera las migraciones descubiertas, omitiendo las que ya están en el registro.

La PRIMARY KEY (version) es importante. Si por algún motivo la misma migración se ejecuta dos veces (activación concurrente, error en el ejecutor), el segundo INSERT INTO ... VALUES (1, ...) falla con un error de clave duplicada, evitando así la corrupción de datos.

Cuándo se invoca el ejecutor

Hay tres disparadores que ejecutan las migraciones:

// 1. Plugin activation (first install + upgrade)
register_activation_hook(__FILE__, function () {
    (new AvaliarDatabaseMigrations())->runPending();
});

// 2. Plugin update (when WordPress detects a new version)
add_action('upgrader_process_complete', function ($upgrader, $hook_extra) {
    if ($hook_extra['type'] === 'plugin'
        && in_array(plugin_basename(AVALIAR_FILE), $hook_extra['plugins'] ?? [])) {
        (new AvaliarDatabaseMigrations())->runPending();
    }
}, 10, 2);

// 3. Manual via WP-CLI (for staging environments without UI)
WP_CLI::add_command('avaliar migrate', function () {
    (new AvaliarDatabaseMigrations())->runPending();
    WP_CLI::success('Migrations complete');
});

El hook de activación gestiona las instalaciones nuevas. El hook upgrader_process_complete gestiona las actualizaciones enviadas a través del administrador de WordPress o mediante comandos de WP-CLI para plugins. El comando personalizado de WP-CLI permite que un administrador de sistemas fuerce la ejecución de migraciones en un servidor (útil cuando se han copiado manualmente los archivos del plugin y es necesario provocar la actualización del esquema).

Los tres llaman al mismo runPending(). Es idempotente: llamarlo dos veces no supone ningún problema, ya que el registro filtra las migraciones ya aplicadas.

Las migraciones como transformaciones de datos, no solo cambios de esquema

Aquí es donde contar con un registro real resulta valioso. A veces una migración no es solo un ALTER TABLE, sino también un relleno de datos. Por ejemplo, cuando añadí la columna school_id a la tabla students (preparando el sistema para multitenencia), también necesitaba rellenarla con el valor 1 para todas las filas existentes:

// includes/Database/migrations/002_add_school_id_to_students.php

return new class implements Migration {
    public function version(): int { return 2; }
    public function name(): string { return 'add_school_id_to_students'; }

    public function up(): void
    {
        global $wpdb;
        $table = "{$wpdb->prefix}avaliar_students";

        // 1. Add the column, nullable initially
        $wpdb->query("ALTER TABLE {$table} ADD COLUMN school_id BIGINT UNSIGNED NULL");

        // 2. Backfill: every existing student belongs to school_id = 1
        $wpdb->query("UPDATE {$table} SET school_id = 1 WHERE school_id IS NULL");

        // 3. Now make it NOT NULL with default for safety
        $wpdb->query("ALTER TABLE {$table} MODIFY COLUMN school_id BIGINT UNSIGNED NOT NULL DEFAULT 1");

        // 4. Add an index
        $wpdb->query("ALTER TABLE {$table} ADD KEY idx_school (school_id)");
    }
};

Esta es una migración de varios pasos que debe ejecutarse exactamente una vez. Con dbDelta, no sería posible realizar el paso 2 (el relleno retroactivo), ya que solo gestiona el esquema. Con mi ejecutor, la migración es simplemente código PHP; todo lo que se pueda hacer en PHP, se puede hacer en una migración.

Claves foráneas: el elefante en la habitación

Seré honesto: todavía no he añadido claves foráneas, aunque el documento de arquitectura las contempla. El motivo es que me encuentro en una fase de desarrollo temprana en la que los cambios de esquema son frecuentes, y las claves foráneas dificultan dichos cambios. Añadir una columna a la tabla A referenciada por la clave foránea de la tabla B requiere eliminar primero esa clave foránea.

Mi plan, documentado como deuda técnica consciente:

Architecture decision (April 2026):
- v0.1 to v0.5: no FKs in schema. Integrity enforced at application
  layer via Repositories + Domain Services.
- v0.6+: introduce migration `0NN_add_foreign_keys.php` that uses
  `ALTER TABLE ... ADD CONSTRAINT` directly. This works where dbDelta
  fails. Justification: DB-level integrity is more robust than app-level
  in scenarios involving bulk imports, manual data manipulation via
  WP-CLI/Adminer, or repository-layer bugs.

La migración en el momento de añadirlas tendrá el siguiente aspecto:

return new class implements Migration {
    public function version(): int { return 15; }
    public function name(): string { return 'add_foreign_keys'; }

    public function up(): void
    {
        global $wpdb;
        $p = $wpdb->prefix;

        $wpdb->query("
            ALTER TABLE {$p}avaliar_students
            ADD CONSTRAINT fk_students_school
            FOREIGN KEY (school_id) REFERENCES {$p}avaliar_schools (id)
            ON DELETE RESTRICT ON UPDATE CASCADE
        ");

        $wpdb->query("
            ALTER TABLE {$p}avaliar_classes
            ADD CONSTRAINT fk_classes_subject
            FOREIGN KEY (subject_id) REFERENCES {$p}avaliar_subjects (id)
            ON DELETE RESTRICT ON UPDATE CASCADE
        ");

        // ... etc
    }
};

De forma crítica: esto funciona porque cada ALTER TABLE ADD CONSTRAINT se ejecuta una vez, en una secuencia conocida, después de que existan todas las tablas referenciadas. dbDelta habría tenido dificultades porque no puede razonar fácilmente sobre dependencias entre tablas.

Tests de integración: la aplicación estrella

El mayor beneficio de contar con un sistema de migraciones determinista y ordenado: puedo construir el esquema desde cero en la configuración de los tests, ejecutar mi lógica de dominio contra él y desmontarlo. Sin depender de un archivo de «fixtures de prueba» que se desvía del esquema de producción.

abstract class IntegrationTestCase extends WP_UnitTestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        $this->dropAllAvaliarTables();
        (new AvaliarDatabaseMigrations())->runPending();
    }

    private function dropAllAvaliarTables(): void
    {
        global $wpdb;
        $tables = $wpdb->get_col(
            "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
             WHERE TABLE_SCHEMA = DATABASE()
               AND TABLE_NAME LIKE '{$wpdb->prefix}avaliar_%'"
        );
        $wpdb->query('SET FOREIGN_KEY_CHECKS = 0');
        foreach ($tables as $t) {
            $wpdb->query("DROP TABLE IF EXISTS {$t}");
        }
        $wpdb->query('SET FOREIGN_KEY_CHECKS = 1');
    }
}

Cada test de integración comienza con un esquema limpio generado al ejecutar todas las migraciones en orden. Esto detecta una categoría de errores en los que una migración funciona contra un esquema existente pero falla contra una instalación limpia (o viceversa).

Lo que aprendí

Lo simple es robusto. El ejecutor tiene 80 líneas y cero dependencias más allá de $wpdb y el sistema de archivos. No hay nada en él que no pueda depurar en cinco minutos. He visto librerías de migraciones con cientos de líneas de magia — no quiero esa responsabilidad en mi plugin.

El registro es innegociable. Sin él, no tienes migraciones — tienes una sincronización de esquema optimista. Todo el valor de las migraciones proviene de «sabemos qué se ha aplicado».

Las migraciones de bajada suelen ser mala idea. Nunca he revertido una base de datos de producción mediante un método down() que haya funcionado. En incidentes reales, se escribe una nueva migración que corrige el estado roto. Construir un mecanismo down() da la falsa tranquilidad de pensar que se puede hacer rollback, cuando en la práctica no es seguro hacerlo.

La idempotencia lo es todo. runPending() debe poder llamarse cualquier número de veces de forma segura. El registro garantiza esto. Una migración que ya ha sido aplicada se omite silenciosamente. Los hooks de activación se disparan múltiples veces en instalaciones multisitio — no puedes permitirte una migración que falle la segunda vez.

El SQL plano supera a dbDelta para esquemas no triviales. Una vez que tienes control sobre el orden de las migraciones y el registro, el ALTER TABLE puro es más predecible que la heurística de dbDelta. Escribes lo que pretendes. La base de datos hace lo que escribiste.

Si estás desarrollando un plugin de WordPress con más de tres tablas y algún tipo de evolución del esquema, escribe tu propio gestor de migraciones. Es un trabajo de una tarde que se amortiza a lo largo de toda la vida del proyecto. Y si quieres ayuda para pensar en tu esquema específico y tu estrategia de migración, estoy disponible.

#migrations #mysql #php #software-architecture #wordpress

Compartir artículo

Últimos Insights