O
dbDelta()integrado no WordPress é suficiente para plugins simples. No momento em que o seu esquema se torna não trivial — chaves estrangeiras, ALTER TABLE, migrações ordenadas — torna-se um risco. Aqui está o executor de migrações leve que escrevi em 100 linhas e que resolveu o problema.
Os plugins do WordPress gerem as suas próprias tabelas de base de dados através de uma função chamada dbDelta(). Fornece-lhe uma instrução CREATE TABLE, ela determina o que já existe e faz os ajustes necessários. Adicionar uma coluna? O dbDelta adiciona-a. Remover uma coluna? O dbDelta… normalmente não o faz. Alterar um tipo de coluna? Talvez. Adicionar uma chave estrangeira? Quase certamente não de uma forma em que possa confiar.
Para um plugin com três tabelas e um esquema simples, o dbDelta é adequado. Para qualquer coisa mais séria, é uma armadilha.
Estou a desenvolver um plugin com cerca de vinte tabelas de domínio, relações de chave estrangeira entre a maioria delas, e um roadmap que inclui adicionar colunas, alterar índices e renomear elementos. Experimentei o dbDelta para a primeira migração. Na segunda, já tinha escrito o meu próprio executor.
Este artigo apresenta o executor — cerca de 100 linhas de PHP — e explica por que razão cada parte existe.
Por que o dbDelta não é suficiente
Antes de mostrar o que construí, deixe-me ser específico sobre onde o dbDelta falha. A documentação oficial do WordPress lista algumas particularidades. As que me afetaram:
As chaves estrangeiras são pouco fiáveis. O dbDelta analisa a sua instrução CREATE TABLE para compreender o que deve existir e, em seguida, emite instruções ALTER TABLE conforme necessário. Mas o seu parser é frágil quando confrontado com a sintaxe FOREIGN KEY ... REFERENCES ... ON DELETE RESTRICT. Em execuções subsequentes, por vezes tenta recriar a chave estrangeira, falha porque ela já existe e regista um erro vago no log de erros do WordPress. O comportamento depende do ambiente — funciona no MariaDB 10.6, falha no MySQL 8.0, ou vice-versa.
Sem conceito de versão. O dbDelta analisa a sua string de “esquema desejado” e o estado atual da base de dados, e tenta convergir para esse estado. Se alterar o esquema desejado entre deployments, pode detetar a alteração, ou pode não o fazer. Não existe registo de quais as migrações que foram executadas. Nem forma de escrever uma migração que preencha dados retroativamente e que seja executada apenas uma vez.
A ordenação é implícita. Se a migração B depende de a migração A ter sido executada, o dbDelta não tem o conceito de “executar primeiro A, depois B”. Ou as agrupa (e perde granularidade) ou confia que o seu hook de ativação as executa pela ordem do código-fonte (o que é frágil).
Sem migrações descendentes. Se adicionar uma coluna e depois precisar de a remover, o dbDelta não a irá eliminar. Tem de escrever o ALTER TABLE DROP COLUMN manualmente, lembrar-se de o remover da instrução CREATE TABLE e torcer para que ninguém esteja a executar a versão antiga.
Para o meu projeto, precisei de:
- Uma lista ordenada de migrações, cada uma com uma versão única
- Um registo das migrações que já foram executadas
ALTER TABLEreal para adicionar chaves estrangeiras após a criação das tabelas- A capacidade de preencher retroativamente ou transformar dados dentro de uma migração
- Testes de integração que possam reconstruir o esquema de raiz de forma determinística
Por isso, escrevi-o.
O executor de migrações
Aqui está a classe 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']
);
}
}
São cerca de 80 linhas. Deixe-me explicar as opções de design.
A interface de migração
Cada migração é uma classe que implementa esta pequena interface:
namespace AvaliarDatabase;
interface Migration
{
public function version(): int;
public function name(): string;
public function up(): void;
}
Um ficheiro de migração tem o seguinte aspeto:
// 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
}
};
Repare em três aspetos:
A migração é uma classe anónima retornada. Isto evita poluir qualquer namespace, confere a cada ficheiro um isolamento completo e permite múltiplas migrações no mesmo projeto sem colisões de nomes.
Apenas up() — sem down(). Tomei uma decisão deliberada de não suportar migrações reversíveis. No mundo real, os dados de produção são os dados de produção. Os rollbacks são feitos escrevendo uma nova migração de “correção”, não executando down() na esperança de que a base de dados regresse a um estado anterior. A simplicidade traz clareza.
Chamadas diretas a $wpdb->query(), não a dbDelta(). Utilizo instruções CREATE TABLE e ALTER TABLE em bruto. Executam uma única vez, são previsíveis, e wpdb->query devolve false em caso de falha (o que verifico no código de produção).
Por que razão o número de versão está no conteúdo do ficheiro e não no nome do ficheiro
Pode reparar que o ficheiro se chama 001_initial_schema.php mas a classe devolve version() === 1. Porquê ambos?
O prefixo do nome do ficheiro (001_) serve para ordenação. O glob() devolve os ficheiros por ordem alfabética, portanto 001_* vem antes de 002_*, que vem antes de 010_*. Isto torna a ordem óbvia para qualquer pessoa que leia o diretório.
O inteiro em version() é a versão canónica. É o que fica armazenado na tabela de registo. O nome do ficheiro pode mudar (por exemplo, alguém muda o nome de 001_initial_schema.php para 001_create_initial_tables.php) e o registo não quebra — a versão continua a ser 1.
Esta separação é importante porque os nomes de ficheiros podem variar. As versões não podem.
Tabela de registo: a fonte de verdade
A tabela de registo é uma tabela simples com três colunas:
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)
);
Após a execução, o resultado é o seguinte:
| version | name | applied_at |
|---|---|---|
| 1 | initial_schema | 2026-04-18 14:32:01 |
| 2 | add_config_overrides | 2026-04-18 14:32:02 |
| 3 | class_owner | 2026-04-19 09:15:33 |
Quando runPending() é chamado, consulta esta tabela para obter a lista de versões aplicadas e, em seguida, itera as migrações descobertas, ignorando as que já constam no registo.
A PRIMARY KEY (version) é importante. Se por algum motivo a mesma migração for executada duas vezes (ativação concorrente, erro no executor), o segundo INSERT INTO ... VALUES (1, ...) falha com um erro de chave duplicada, evitando corrupção de dados.
Quando o executor é invocado
Três gatilhos executam as migrações:
// 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');
});
O hook de ativação trata das novas instalações. O hook upgrader_process_complete trata das atualizações efetuadas através do painel de administração do WordPress ou através de comandos WP-CLI para plugins. O comando WP-CLI personalizado permite que um administrador de sistemas force a execução das migrações num servidor (útil quando os ficheiros do plugin foram copiados manualmente e é necessário acionar a atualização do esquema).
Os três chamam o mesmo runPending(). É idempotente — chamá-lo duas vezes não causa problemas, pois o registo filtra as migrações já aplicadas.
Migrações como transformações de dados, não apenas alterações de esquema
É aqui que ter um registo real compensa. Por vezes, uma migração não é apenas um ALTER TABLE — implica também o preenchimento retroativo de dados. Por exemplo, quando adicionei a coluna school_id à tabela students (em preparação para multi-tenancy), precisei também de a preencher com o valor 1 em todas as linhas 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 é uma migração com múltiplos passos que tem de ser executada exatamente uma vez. Com o dbDelta, não seria possível realizar o passo 2 (o backfill) — ele apenas trata do esquema. Com o meu executor, a migração é simplesmente código PHP; tudo o que for possível fazer em PHP, pode ser feito numa migração.
Chaves estrangeiras: o elefante na sala
Sendo honesto: ainda não adicionei chaves estrangeiras, apesar de o documento de arquitetura as prever. O motivo é que ainda me encontro numa fase inicial de desenvolvimento em que as alterações ao esquema são frequentes, e as FKs tornam essas alterações mais difíceis. Adicionar uma coluna à tabela A referenciada pela FK da tabela B exige remover primeiro a FK.
O meu plano, documentado como dívida 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.
A migração, quando as adicionar, terá o seguinte aspeto:
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: isto funciona porque cada ALTER TABLE ADD CONSTRAINT é executado uma vez, numa sequência conhecida, depois de todas as tabelas referenciadas existirem. O dbDelta teria dificuldades porque não consegue raciocinar facilmente sobre dependências entre tabelas.
Testes de integração: a aplicação matadora
O maior benefício de ter um sistema de migrações determinístico e ordenado: posso construir o esquema de raiz na configuração dos testes, executar a minha lógica de domínio sobre ele e desmontá-lo. Sem depender de um ficheiro de “fixtures de teste” que diverge do esquema de produção.
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 teste de integração começa com um esquema limpo gerado pela execução de todas as migrações por ordem. Isto deteta uma classe de erros em que uma migração funciona contra um esquema existente mas falha numa instalação limpa (ou vice-versa).
O que aprendi
O simples é robusto. O executor tem 80 linhas e zero dependências além de $wpdb e do sistema de ficheiros. Não há nada nele que não consiga depurar em cinco minutos. Já vi bibliotecas de migrações com centenas de linhas de magia — não quero essa responsabilidade no meu plugin.
O registo é inegociável. Sem ele, não tem migrações — tem sincronização de esquema esperançosa. Todo o valor das migrações vem de “sabemos o que foi aplicado”.
As migrações descendentes são geralmente uma má ideia. Nunca revertei uma base de dados de produção através de um método down() que tenha funcionado. Em incidentes reais, escreve-se uma nova migração que corrige o estado danificado. Construir um mecanismo down() dá-lhe o falso conforto de pensar que pode reverter, quando na prática não é possível fazê-lo com segurança.
A idempotência é tudo. O runPending() deve ser seguro de invocar qualquer número de vezes. O registo garante isso. Uma migração que já foi aplicada é ignorada silenciosamente. Os hooks de ativação disparam várias vezes em instalações multi-site — não se pode dar ao luxo de ter uma migração que falha na segunda execução.
SQL simples supera o dbDelta para esquemas não triviais. Assim que tiver controlo sobre a ordenação e o registo das migrações, o ALTER TABLE direto é mais previsível do que as heurísticas do dbDelta. Escreve o que pretende. A base de dados executa o que escreveu.
Se está a desenvolver um plugin WordPress com mais de três tabelas e algum tipo de evolução de esquema, escreva o seu próprio executor de migrações. É um trabalho de uma tarde que compensa ao longo de toda a vida útil do projeto. E se quiser ajuda para pensar na sua estratégia específica de esquema e migrações, estou disponível.