Как внедрить систему миграций в WordPress без хаоса
Структура migrations/, версионирование, запись версии в option, Up/Down. Пример создания таблицы и добавления meta. Ошибка при повторном запуске и сравнение с Laravel.
Требования
- WordPress 5.0+
- PHP 7.4+
- MySQL 5.6+
Как внедрить систему миграций в WordPress без хаоса
В WordPress нет встроенных миграций, как в Laravel: изменения БД часто делают вручную или разовыми скриптами, и при обновлении плагина/темы непонятно, что уже применено. Ниже — как завести порядок: структура каталога migrations/, версионирование, хранение версии в option, методы Up/Down, примеры (создание таблицы и регистрация meta), что ломается при повторном запуске и чем такой подход похож на Laravel и чем отличается.
Структура migrations/
Миграции хранятся в одном каталоге, по одному файлу на изменение. Имя файла задаёт порядок выполнения.
wp-content/plugins/my-plugin/
├── migrations/
│ ├── 001_create_orders_table.php
│ ├── 002_add_meta_to_orders.php
│ └── 003_register_order_status_meta.php
├── includes/
│ └── class-migration-runner.php
└── my-plugin.php
Или в теме:
wp-content/themes/my-theme/
├── migrations/
│ ├── 001_create_events_table.php
│ └── 002_add_event_meta.php
└── functions.php
Версионирование — по префиксу номера в имени файла (001_, 002_, …). Можно использовать и timestamp (20260114120000_create_orders_table.php), тогда порядок по алфавиту совпадает с хронологией.
Версионирование и запись версии в option
Чтобы не запускать одну и ту же миграцию дважды, нужно хранить список уже выполненных версий. Удобно — в option.
Пример ключа: my_plugin_migrations_version — массив имён выполненных миграций или одна строка «последняя версия» (если миграции выполняются строго по порядку).
Вариант 1 — хранить последнюю версию (число или строка с номером):
// Получить текущую версию
$current = (int) get_option( 'my_plugin_migrations_version', 0 );
// После успешного выполнения миграции 003
update_option( 'my_plugin_migrations_version', 3 );
Вариант 2 — хранить массив выполненных файлов (удобно при нелинейной истории):
$executed = get_option( 'my_plugin_migrations_version', [] );
if ( ! is_array( $executed ) ) {
$executed = [];
}
// Проверка: миграция уже выполнялась?
if ( in_array( '002_add_meta_to_orders', $executed, true ) ) {
return;
}
// После успешного up()
$executed[] = '002_add_meta_to_orders';
update_option( 'my_plugin_migrations_version', $executed );
Так runner при каждом запуске сравнивает список файлов в migrations/ с записью в option и выполняет только новые миграции.
Up / Down
У каждой миграции два метода:
- up() — применить изменение (создать таблицу, добавить колонку, зарегистрировать meta).
- down() — откатить изменение (удалить таблицу, колонку, убрать meta).
Runner при «миграции вперёд» вызывает только up() у ещё не выполненных файлов; при откате — вызывает down() у выбранных миграций (обычно в обратном порядке).
Пример каркаса файла миграции:
<?php
// migrations/001_create_orders_table.php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Migration_001_Create_Orders_Table {
public function up() {
global $wpdb;
$table = $wpdb->prefix . 'orders';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS $table (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL,
total decimal(10,2) NOT NULL DEFAULT 0,
created_at datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY user_id (user_id)
) $charset;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
public function down() {
global $wpdb;
$table = $wpdb->prefix . 'orders';
$wpdb->query( "DROP TABLE IF EXISTS `$table`" );
}
}
Runner подключает файл, создаёт экземпляр класса (или вызывает статические методы), перед вызовом up() проверяет по option, что эта миграция ещё не выполнялась; после успешного up() обновляет option.
Пример создания таблицы
Полный пример: создание таблицы с префиксом сайта и кодировкой.
<?php
// migrations/001_create_orders_table.php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Migration_001_Create_Orders_Table {
public function up() {
global $wpdb;
$table = $wpdb->prefix . 'orders';
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS `$table` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL,
status varchar(20) NOT NULL DEFAULT 'pending',
total decimal(10,2) NOT NULL DEFAULT 0.00,
created_at datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY user_id (user_id),
KEY status (status)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
public function down() {
global $wpdb;
$table = $wpdb->prefix . 'orders';
$wpdb->query( "DROP TABLE IF EXISTS `$table`" );
}
}
Важно: CREATE TABLE IF NOT EXISTS и dbDelta() уменьшают риск падения при случайном повторном запуске (см. ниже). В down() таблицу можно смело дропать.
Пример добавления meta field
Миграция, которая регистрирует post meta (или option) для типа поста.
<?php
// migrations/002_add_order_status_meta.php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Migration_002_Add_Order_Status_Meta {
public function up() {
register_post_meta(
'shop_order',
'_order_source',
[
'type' => 'string',
'description' => 'Источник заказа',
'single' => true,
'show_in_rest' => true,
'default' => 'site',
]
);
}
public function down() {
unregister_post_meta( 'shop_order', '_order_source' );
}
}
Для кастомного типа поста замени shop_order на свой тип. Если meta уже зарегистрирована в коде темы/плагина, в миграции можно только добавлять данные или менять дефолты; повторная регистрация одного и того же meta при повторном запуске обычно не ломает WordPress, но учёт версии в option избавляет от лишних вызовов.
Ошибка при повторном запуске
Если не хранить выполненные миграции и каждый раз вызывать все файлы:
- CREATE TABLE без IF NOT EXISTS — при втором запуске MySQL вернёт ошибку «Table already exists».
- ALTER TABLE ADD COLUMN без проверки — «Duplicate column name».
- Регистрация meta или создание постов/терминов — дубликаты или лишние записи.
Итог: либо миграции идемпотентны (проверка существования таблицы/колонки перед созданием/добавлением), либо выполняется только то, что ещё не записано в option.
Рекомендация: всегда вести учёт в option и запускать только новые миграции; внутри миграции при необходимости делать проверки (например, существование колонки перед ALTER TABLE ADD).
Пример идемпотентного добавления колонки:
public function up() {
global $wpdb;
$table = $wpdb->prefix . 'orders';
$col = 'phone';
$exists = $wpdb->get_results(
$wpdb->prepare(
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s",
DB_NAME,
$table,
$col
)
);
if ( ! empty( $exists ) ) {
return;
}
$wpdb->query( "ALTER TABLE `$table` ADD COLUMN `$col` varchar(50) DEFAULT NULL" );
}
Так повторный запуск одной и той же миграции не приведёт к ошибке.
Сравнение с Laravel migrations
| Аспект | Laravel | WordPress (своя система) |
|---|---|---|
| Команда запуска | php artisan migrate | Свой код: по хуку (admin_init, активация плагина) или WP-CLI |
| Хранение версий | Таблица migrations (файл в строке) | Option: get_option('my_plugin_migrations_version') |
| Формат файла | Класс с up() / down() | То же: класс или функции up() / down() в файле |
| Имя файла | Timestamp + описание | Номер + описание или timestamp |
| Откат | php artisan migrate:rollback | Свой runner: вызов down() по списку из option |
| Создание таблиц | Schema::create() | $wpdb->query() + dbDelta() |
| Безопасность | Транзакции, миграции по одной | Нет встроенных транзакций в WP по умолчанию; можно оборачивать в START TRANSACTION / COMMIT вручную |
Общая идея та же: версионированные файлы, применение только новых, возможность отката. В WordPress нет Artisan и своей таблицы миграций «из коробки», поэтому версию храним в option и runner пишем сами (или подключаем готовый плагин с такой логикой).
Минимальный runner (идея)
Чтобы не раздувать статью, ниже только идея вызова миграций.
- Сканировать каталог
migrations/— получить список файлов, отсортировать по имени. - Загрузить из option список выполненных (например, массив имён или последний номер).
- Для каждого файла, которого ещё нет в списке: подключить файл, вызвать
up(), при успехе добавить в список и сохранить в option. - При откате: взять последнюю выполненную миграцию из option, подключить файл, вызвать
down(), убрать запись из option.
Проверка синтаксиса и прав доступа (например, только для администратора при ручном запуске) — в самом runner. Так ты получаешь предсказуемое применение изменений БД без хаоса и без повторного выполнения одних и тех же миграций.
Кратко
- migrations/ — один каталог, файлы с номерами или timestamp в имени; порядок по имени.
- Версионирование — номер или имя файла; запись в option — какие миграции уже выполнены; запускать только новые.
- Up / Down — в каждом файле; up — применить, down — откатить.
- Создание таблицы —
dbDelta()иCREATE TABLE IF NOT EXISTS; в down —DROP TABLE IF EXISTS. - Meta field —
register_post_meta()в up,unregister_post_meta()в down. - Повторный запуск — без учёта версии получишь ошибки «table/column already exists»; решается учётом в option и при необходимости проверками в самой миграции.
- По духу подход совпадает с Laravel: версионированные миграции, учёт выполненных, откат через down; отличия — нет Artisan и таблицы миграций, версия хранится в option.



Комментарии