← Назад в блог

Как внедрить систему миграций в WordPress без хаоса

Структура migrations/, версионирование, запись версии в option, Up/Down. Пример создания таблицы и добавления meta. Ошибка при повторном запуске и сравнение с Laravel.

Как внедрить систему миграций в WordPress без хаоса

Требования

  • 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

АспектLaravelWordPress (своя система)
Команда запуска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 (идея)

Чтобы не раздувать статью, ниже только идея вызова миграций.

  1. Сканировать каталог migrations/ — получить список файлов, отсортировать по имени.
  2. Загрузить из option список выполненных (например, массив имён или последний номер).
  3. Для каждого файла, которого ещё нет в списке: подключить файл, вызвать up(), при успехе добавить в список и сохранить в option.
  4. При откате: взять последнюю выполненную миграцию из option, подключить файл, вызвать down(), убрать запись из option.

Проверка синтаксиса и прав доступа (например, только для администратора при ручном запуске) — в самом runner. Так ты получаешь предсказуемое применение изменений БД без хаоса и без повторного выполнения одних и тех же миграций.


Кратко

  • migrations/ — один каталог, файлы с номерами или timestamp в имени; порядок по имени.
  • Версионирование — номер или имя файла; запись в option — какие миграции уже выполнены; запускать только новые.
  • Up / Down — в каждом файле; up — применить, down — откатить.
  • Создание таблицыdbDelta() и CREATE TABLE IF NOT EXISTS; в down — DROP TABLE IF EXISTS.
  • Meta fieldregister_post_meta() в up, unregister_post_meta() в down.
  • Повторный запуск — без учёта версии получишь ошибки «table/column already exists»; решается учётом в option и при необходимости проверками в самой миграции.
  • По духу подход совпадает с Laravel: версионированные миграции, учёт выполненных, откат через down; отличия — нет Artisan и таблицы миграций, версия хранится в option.
0 просмотров

Комментарии

Загрузка комментариев...
Пока нет комментариев. Будьте первым!