Aller au contenu
Retour à tous les articles
Architecture Development WordPress 4 May 2026 · 14 min de lecture

Pourquoi j’ai développé mon propre système de migrations plutôt que d’utiliser dbDelta

Francisco Silva

Francisco Silva

Partenaire Senior d'Ingénierie WordPress.

Pourquoi j’ai développé mon propre système de migrations plutôt que d’utiliser dbDelta

La fonction intégrée dbDelta() de WordPress convient parfaitement aux plugins simples. Dès que votre schéma devient non trivial — clés étrangères, ALTER TABLE, migrations ordonnées — elle devient un risque. Voici le runner de migrations léger que j’ai écrit en 100 lignes pour résoudre ce problème.

Les plugins WordPress gèrent leurs propres tables de base de données via une fonction appelée dbDelta(). Vous lui fournissez une instruction CREATE TABLE, elle détermine ce qui existe déjà et effectue les ajustements nécessaires. Ajouter une colonne ? dbDelta l’ajoutera. Supprimer une colonne ? dbDelta… généralement pas. Modifier un type de colonne ? Peut-être. Ajouter une clé étrangère ? Presque certainement pas d’une manière sur laquelle vous pouvez compter.

Pour un plugin avec trois tables et un schéma plat, dbDelta fait l’affaire. Pour quoi que ce soit de sérieux, c’est un piège à éviter.

Je développe un plugin avec une vingtaine de tables métier, des relations de clés étrangères entre la plupart d’entre elles, et une feuille de route qui prévoit l’ajout de colonnes, la modification d’index et des renommages. J’ai essayé dbDelta pour la première migration. Dès la deuxième, j’avais écrit mon propre runner.

Cet article présente le runner — environ 100 lignes de PHP — et explique pourquoi chaque partie existe.

Pourquoi dbDelta ne suffit pas

Avant de vous montrer ce que j’ai construit, permettez-moi d’être précis sur les points où dbDelta échoue. La documentation officielle de WordPress liste quelques particularités. Celles qui m’ont posé problème :

Les clés étrangères ne sont pas fiables. dbDelta analyse votre instruction CREATE TABLE pour comprendre ce qui devrait exister, puis émet des instructions ALTER TABLE selon les besoins. Mais son parseur est fragile face à la syntaxe FOREIGN KEY ... REFERENCES ... ON DELETE RESTRICT. Lors des exécutions suivantes, il tente parfois de recréer la clé étrangère, échoue parce qu’elle existe déjà, et inscrit une erreur vague dans le journal d’erreurs de WordPress. Le comportement dépend de l’environnement — fonctionne sur MariaDB 10.6, échoue sur MySQL 8.0, ou inversement.

Aucune notion de version. dbDelta examine votre chaîne de « schéma souhaité » et l’état actuel de la base de données, puis tente de les faire converger. Si vous modifiez le schéma souhaité entre deux déploiements, il peut détecter le changement ou non. Il n’existe aucun enregistrement des migrations déjà exécutées. Aucun moyen d’écrire une migration qui réalimente des données et ne s’exécute qu’une seule fois.

L’ordre est implicite. Si la migration B dépend de l’exécution préalable de la migration A, dbDelta n’a aucune notion de « exécuter A en premier, puis B ». Vous devez soit les regrouper (et perdre en granularité), soit espérer que votre hook d’activation les exécute dans l’ordre du code source (ce qui est fragile).

Pas de migrations descendantes. Si vous ajoutez une colonne puis devez la supprimer, dbDelta ne la supprimera pas. Vous devez écrire vous-même le ALTER TABLE DROP COLUMN, penser à le retirer de l’instruction CREATE TABLE, et espérer que personne n’utilise l’ancienne version.

Pour mon projet, j’avais besoin de :

  • Une liste ordonnée de migrations, chacune avec une version unique
  • Un registre des migrations déjà exécutées
  • Un véritable ALTER TABLE pour l’ajout de clés étrangères après la création des tables
  • La possibilité de remplir ou de transformer des données à l’intérieur d’une migration
  • Des tests d’intégration capables de reconstruire le schéma from scratch de manière déterministe

Alors je l’ai écrit.

Le gestionnaire de migrations

Voici l’intégralité de la classe Migrations :

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']
        );
    }
}

C’est tout. Environ 80 lignes. Laissez-moi vous présenter les choix de conception.

L’interface de migration

Chaque migration est une classe implémentant cette petite interface :

namespace AvaliarDatabase;

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

Un fichier de migration ressemble à ceci :

// 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
    }
};

Remarquez trois points :

La migration est une classe anonyme retournée. Cela évite de polluer un espace de noms quelconque, offre à chaque fichier une isolation complète et permet d’avoir plusieurs migrations dans le même projet sans collision de noms.

up() uniquement — pas de down(). J’ai fait le choix délibéré de ne pas prendre en charge les migrations réversibles. Dans le monde réel, les données de production sont les données de production. Les retours en arrière s’effectuent en écrivant une nouvelle migration corrective, et non en exécutant down() en espérant que la base de données retrouve un état antérieur. Cette simplicité apporte de la clarté.

Appels directs à $wpdb->query(), pas à dbDelta(). J’utilise des instructions CREATE TABLE et ALTER TABLE brutes. Elles s’exécutent une seule fois, leur comportement est prévisible, et wpdb->query retourne false en cas d’échec (ce que je vérifie dans le code de production).

Pourquoi le numéro de version se trouve dans le contenu du fichier et non dans son nom

Vous avez peut-être remarqué que le fichier s’appelle 001_initial_schema.php mais que la classe retourne version() === 1. Pourquoi les deux ?

Le préfixe du nom de fichier (001_) sert à l’ordonnancement. glob() retourne les fichiers par ordre alphabétique, donc 001_* vient avant 002_*, qui vient avant 010_*. Cela rend l’ordre évident pour quiconque consulte le répertoire.

L’entier dans version() est la version canonique. C’est elle qui est stockée dans la table de registre. Le nom du fichier peut changer (par exemple si quelqu’un renomme 001_initial_schema.php en 001_create_initial_tables.php) sans que le registre ne soit affecté — la version reste 1.

Cette séparation est importante car les noms de fichiers peuvent évoluer. Les versions, non.

La table de registre : la source de vérité

La table de registre est une table unique composée de trois colonnes :

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)
);

Après exécution, le résultat ressemble à :

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

Lorsque runPending() est appelée, elle interroge cette table pour obtenir la liste des versions appliquées, puis itère sur les migrations découvertes en ignorant celles qui sont déjà présentes dans le registre.

La PRIMARY KEY (version) a son importance. Si, pour une raison quelconque, la même migration s’exécute deux fois (activation concurrente, bug dans le runner), le second INSERT INTO ... VALUES (1, ...) échoue avec une erreur de clé dupliquée, ce qui empêche toute corruption des données.

Quand le runner est invoqué

Trois déclencheurs exécutent les migrations :

// 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');
});

Le hook d’activation prend en charge les nouvelles installations. Le hook upgrader_process_complete gère les mises à jour déployées via l’interface d’administration WordPress ou les commandes WP-CLI. La commande WP-CLI personnalisée permet à un administrateur système de forcer l’exécution des migrations sur un serveur (utile lorsque vous avez copié manuellement les fichiers du plugin et devez déclencher la mise à jour du schéma).

Les trois appellent le même runPending(). Idempotent — l’appeler deux fois ne pose aucun problème, le registre filtre les migrations déjà appliquées.

Les migrations comme transformations de données, pas seulement des modifications de schéma

C’est là qu’un vrai registre révèle tout son intérêt. Parfois, une migration ne se limite pas à un ALTER TABLE — elle implique également un remplissage de données. Par exemple, lorsque j’ai ajouté la colonne school_id à la table students (en préparation d’une architecture multi-tenant), j’avais aussi besoin de la renseigner avec la valeur 1 pour toutes les lignes existantes :

// 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)");
    }
};

Il s’agit d’une migration en plusieurs étapes qui doit s’exécuter exactement une fois. Avec dbDelta, l’étape 2 (le remplissage rétrospectif) serait impossible — il ne gère que le schéma. Avec mon runner, la migration est simplement du code PHP ; tout ce que vous pouvez faire en PHP, vous pouvez le faire dans une migration.

Les clés étrangères : l’éléphant dans la pièce

Soyons honnêtes : je n’ai pas encore ajouté de clés étrangères, même si le document d’architecture les prévoit. La raison est que je suis encore en phase de développement précoce où les modifications de schéma sont fréquentes, et les clés étrangères compliquent ces changements. L’ajout d’une colonne dans la table A référencée par la clé étrangère de la table B nécessite de supprimer préalablement cette clé étrangère.

Mon plan, documenté en tant que dette technique assumée :

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 migration, lorsque je les ajouterai, ressemblera à ceci :

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
    }
};

Point crucial : cela fonctionne parce que chaque ALTER TABLE ADD CONSTRAINT s’exécute une seule fois, dans un ordre connu, après que toutes les tables référencées existent. dbDelta aurait eu du mal, car il ne peut pas facilement raisonner sur les dépendances entre tables.

Les tests d’intégration : l’argument décisif

Le plus grand avantage d’un système de migrations déterministe et ordonné : je peux construire le schéma from scratch lors de la mise en place des tests, exécuter ma logique métier dessus, puis le supprimer. Plus besoin de s’appuyer sur un fichier de « fixtures de test » qui diverge du schéma de production.

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');
    }
}

Chaque test d’intégration commence avec un schéma vierge généré en exécutant toutes les migrations dans l’ordre. Cela permet de détecter une catégorie de bugs où une migration fonctionne sur un schéma existant mais échoue sur une installation propre (ou inversement).

Ce que j’ai appris

La simplicité est synonyme de robustesse. Le runner fait 80 lignes et n’a aucune dépendance au-delà de $wpdb et du système de fichiers. Il n’y a rien dedans que je ne puisse déboguer en cinq minutes. J’ai vu des bibliothèques de migration avec des centaines de lignes de magie — je ne veux pas cette responsabilité dans mon plugin.

Le registre est non négociable. Sans lui, vous n’avez pas de migrations — vous avez une synchronisation de schéma fondée sur l’espoir. Toute la valeur des migrations repose sur le fait de « savoir ce qui a été appliqué ».

Les migrations descendantes sont généralement une mauvaise idée. Je n’ai jamais réussi à revenir en arrière sur une base de données de production via une méthode down() qui a fonctionné. Dans les incidents réels, on écrit une nouvelle migration qui corrige l’état défaillant. Construire un mécanisme down() donne le faux sentiment qu’on peut effectuer un rollback, alors qu’en pratique ce n’est pas réalisable en toute sécurité.

L’idempotence est primordiale. runPending() doit pouvoir être appelé un nombre quelconque de fois sans risque. Le registre le garantit. Une migration déjà appliquée est ignorée silencieusement. Les hooks d’activation se déclenchent plusieurs fois sur les installations multi-sites — vous ne pouvez pas vous permettre une migration qui échoue la deuxième fois.

Le SQL brut surpasse dbDelta pour les schémas non triviaux. Une fois que vous maîtrisez l’ordre des migrations et le registre, un ALTER TABLE brut est plus prévisible que les heuristiques de dbDelta. Vous écrivez ce que vous voulez dire. La base de données exécute exactement ce que vous avez écrit.

Si vous développez un plugin WordPress avec plus de trois tables et une quelconque évolution du schéma, écrivez votre propre gestionnaire de migrations. C’est un travail d’un après-midi qui se rentabilise sur toute la durée de vie du projet. Et si vous souhaitez réfléchir à votre schéma et à votre stratégie de migration, je suis disponible.

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

Partager l'article

Derniers Insights