2026年9月18日

2026年9月18日

WordPressのカスタムテーブルをSCHEMA_VERSIONで管理する方法|dbDeltaと段階的マイグレーション

プラグインで作ったカスタムテーブルは、コードに「スキーマのバージョン番号(SCHEMA_VERSION)」を持たせ、DB側に保存した番号と比べて差分だけを適用する形にすると、更新のたびに安全に構造を変えられます。この記事では、バージョン番号の置き方、dbDelta() の正しい書き方、列の追加や名前変更を段階的に流す仕組み、マルチサイトとアンインストールの扱いまでを、そのまま使えるコードで説明します。

なぜバージョン番号で管理するのか

カスタムテーブルを作るとき、多くの解説は register_activation_hook() の中で CREATE TABLE を実行する形を紹介しています。ところが有効化フックは「プラグインを有効化した瞬間」にしか動きません。管理画面の更新ボタンや自動更新でプラグインを新しい版に入れ替えたときは発火しないため、新しい版で列を足しても、既存サイトのテーブルは古いままになります。

そこで、次の2つを用意します。

  • コード側: 現在のスキーマ番号を定数(例: SCHEMA_VERSION = 3)で持つ
  • DB側: 実際に適用済みの番号を wp_options に保存する(例: myplugin_db_version)

毎回のリクエストで両者を比べ、DB側が古いときだけ足りない手順を順番に実行します。オプションは自動読み込みされるので、比較そのものの負荷はほとんどありません。番号は「1, 2, 3」と整数で増やすと、どの手順まで済んでいるかが明確になります。プラグイン本体のバージョン(1.4.2 など)とは別に管理するのがポイントで、本体を更新してもテーブル構造が変わらない版では番号を据え置きます。

作業前の準備

  • 本番ではなくステージング環境で先に試します。テーブル構造の変更は戻すのに手間がかかります。
  • 実行前にデータベースのバックアップを取ります。WP-CLI が使えるなら wp db export で十分です。
  • コードはテーマではなく自作プラグイン(またはmu-plugin)に置きます。テーマを切り替えたときにテーブル管理が止まらないようにするためです。

dbDeltaでテーブルを作る

WordPress には dbDelta() という関数があり、渡した CREATE TABLE 文と実際のテーブルを比べて、無ければ作成、列やインデックスが足りなければ追加してくれます。wp-admin/includes/upgrade.php に定義されているので、使う前に読み込みます。

<?php
function myplugin_create_logs_table() {
	global $wpdb;

	$table_name      = $wpdb->prefix . 'myplugin_logs';
	$charset_collate = $wpdb->get_charset_collate();

	$sql = "CREATE TABLE {$table_name} (
  id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  user_id bigint(20) unsigned NOT NULL DEFAULT 0,
  action varchar(64) NOT NULL DEFAULT '',
  created_at datetime NOT NULL,
  PRIMARY KEY  (id),
  KEY user_id (user_id)
) {$charset_collate};";

	require_once ABSPATH . 'wp-admin/includes/upgrade.php';
	return dbDelta( $sql );
}

dbDelta() は文字列を独自に解析するため、書式にくせがあります。公式ハンドブックで挙げられている主な注意点は次のとおりです。

  • 1行に1列ずつ書く
  • PRIMARY KEY と (id) の間は半角スペース2つ
  • インデックスは INDEX ではなく KEY と書く
  • 列名をバッククォートで囲まない
  • 列の型(bigint varchar など)は小文字で書く

書式が崩れていると、エラーにならないまま「毎回同じ列を変更しようとする」「インデックスが追加されない」といった動きになります。戻り値は実行内容の配列なので、開発中は error_log( print_r( $result, true ) ); で中身を見ておくと安心です。

また、dbDelta() は列の追加はできても、列の削除や名前の変更はしません。これが次の「段階的マイグレーション」が必要な理由です。

SCHEMA_VERSIONと段階的マイグレーション

番号ごとに「その版で行う変更」を関数として並べ、DBの番号から目標の番号まで1つずつ実行します。1手順終わるごとに番号を保存するので、途中で失敗しても、次回は失敗した手順から再開できます。

<?php
/**
 * Plugin Name: My Plugin Logs
 */

defined( 'ABSPATH' ) || exit;

final class MyPlugin_Schema {
	const SCHEMA_VERSION = 3;
	const OPTION_KEY     = 'myplugin_db_version';

	public static function maybe_upgrade() {
		$installed = (int) get_option( self::OPTION_KEY, 0 );
		if ( $installed >= self::SCHEMA_VERSION ) {
			return;
		}

		global $wpdb;
		$lock_name = $wpdb->prefix . 'myplugin_schema_lock';

		// 同時アクセスで二重に実行されないよう、MySQL のロックを取る
		$locked = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_name, 0 ) );
		if ( '1' !== (string) $locked ) {
			return;
		}

		for ( $version = $installed + 1; $version <= self::SCHEMA_VERSION; $version++ ) {
			$method = 'migrate_to_' . $version;
			if ( ! method_exists( __CLASS__, $method ) ) {
				break;
			}
			if ( false === self::$method() ) {
				error_log( sprintf( '[myplugin] schema migration to %d failed', $version ) );
				break;
			}
			update_option( self::OPTION_KEY, $version, true );
		}

		$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
	}

	// v1: テーブル作成
	private static function migrate_to_1() {
		myplugin_create_logs_table();
		return true;
	}

	// v2: 列 ip_hash を追加(dbDelta は列の追加に対応)
	private static function migrate_to_2() {
		global $wpdb;
		$table_name      = $wpdb->prefix . 'myplugin_logs';
		$charset_collate = $wpdb->get_charset_collate();

		$sql = "CREATE TABLE {$table_name} (
  id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  user_id bigint(20) unsigned NOT NULL DEFAULT 0,
  action varchar(64) NOT NULL DEFAULT '',
  ip_hash char(64) NOT NULL DEFAULT '',
  created_at datetime NOT NULL,
  PRIMARY KEY  (id),
  KEY user_id (user_id)
) {$charset_collate};";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql );
		return true;
	}

	// v3: 列名 action を event_type に変更(dbDelta では不可なので ALTER)
	private static function migrate_to_3() {
		global $wpdb;
		$table_name = $wpdb->prefix . 'myplugin_logs';

		$has_old = $wpdb->get_var( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $table_name, 'action' ) );
		if ( null === $has_old ) {
			return true; // すでに変更済み
		}
		$result = $wpdb->query(
			$wpdb->prepare( "ALTER TABLE %i CHANGE action event_type varchar(64) NOT NULL DEFAULT ''", $table_name )
		);
		return false !== $result;
	}
}

add_action( 'plugins_loaded', array( 'MyPlugin_Schema', 'maybe_upgrade' ) );
register_activation_hook( __FILE__, array( 'MyPlugin_Schema', 'maybe_upgrade' ) );

上の例で押さえておきたい点を補足します。

新規インストールでも順番に流す: 新しいサイトでも v1 → v2 → v3 と順に適用されます。最新の定義を直接作る書き方もできますが、手順を1本にしておくと「新規と更新で構造が食い違う」事故を防げます。

手順は何度実行しても壊れないように書く: v3 では列の有無を確認してから ALTER TABLE を実行しています。番号の保存前にタイムアウトした場合でも、再実行で二重変更になりません。

ロック: 更新直後にアクセスが集中すると、複数のリクエストが同時にマイグレーションを始めることがあります。GET_LOCK() は MySQL / MariaDB の名前付きロックで、取れなかったリクエストは何もせずに戻ります。ロックは接続が切れると解放されます。

%i プレースホルダー: テーブル名などの識別子を $wpdb->prepare() に渡す %i は WordPress 6.2 で追加されました。それより古い環境を対象にするなら、$wpdb->prefix から組み立てた固定の名前を直接埋め込み、利用者の入力は混ぜないようにします。

大きなテーブルの変更: 数百万行あるテーブルへの ALTER TABLE は時間がかかり、PHP の実行時間制限に当たることがあります。データ量が多い場合は、管理画面からの実行ではなく WP-CLI や保守時間帯での手動実行に切り替えるほうが安全です。

確認方法

適用済みの番号とテーブル構造は WP-CLI で確認できます(テーブル接頭辞が wp_ の場合の例)。

wp option get myplugin_db_version
wp db query "SHOW CREATE TABLE wp_myplugin_logs\G"

番号が目標値になっているのに列が足りない場合は、dbDelta() の書式崩れを疑います。逆に番号が上がらない場合は、error_log の出力(WP_DEBUG_LOG を有効にしていれば wp-content/debug.log)で失敗した手順を探します。

マルチサイトでの扱い

マルチサイトでは $wpdb->prefix がサイトごとに wp_2_ のように変わり、オプションもサイトごとに保存されます。上のコードは各サイトにアクセスがあった時点でそのサイトのテーブルを更新するため、基本的にはそのまま動きます。

新しくサイトが作られたときにすぐテーブルを用意したい場合は、WordPress 5.1 以降の wp_initialize_site アクションを使います。

<?php
add_action( 'wp_initialize_site', function ( $new_site ) {
	if ( ! is_plugin_active_for_network( plugin_basename( __FILE__ ) ) ) {
		return;
	}
	switch_to_blog( $new_site->blog_id );
	MyPlugin_Schema::maybe_upgrade();
	restore_current_blog();
}, 20 );

is_plugin_active_for_network() は管理画面以外では読み込まれていないことがあるため、必要に応じて wp-admin/includes/plugin.php を読み込んでください。

アンインストール時の後片付け

プラグインを削除したときにテーブルとバージョン番号を消すには、プラグインフォルダ直下に uninstall.php を置きます。データを消す処理なので、利用者がデータを残したい場合の設定を用意するかどうかも検討してください。

<?php
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;

global $wpdb;
$table_name = $wpdb->prefix . 'myplugin_logs';
$wpdb->query( "DROP TABLE IF EXISTS {$table_name}" );
delete_option( 'myplugin_db_version' );

つまずきやすい点

  • 番号を下げる(ダウングレード): 古い版のプラグインに戻すと、DB側の番号のほうが大きくなります。上のコードは何もしないので、古いコードが新しい列名を知らずにエラーになることがあります。戻す可能性があるなら、列名の変更のような互換性を壊す変更は避け、列の追加にとどめるのが無難です。
  • オプション名の衝突: db_version のような一般的な名前はWordPress本体や他のプラグインと重なります。プラグイン固有の接頭辞を付けます。
  • 文字コード: get_charset_collate() を付け忘れると、サイトと異なる照合順序でテーブルが作られ、結合や検索で問題が出ます。
  • MySQL / MariaDB の差: RENAME COLUMN 構文は MySQL 8.0 / MariaDB 10.5.2 以降の機能です(2026年10月現在)。古いサーバーも対象なら、例のように CHANGE で型を含めて書き直す方法が使えます。

まとめ

  • 有効化フックだけではプラグイン更新時にテーブルが変わらないため、コード側の SCHEMA_VERSION と wp_options の適用済み番号を比べて差分を流す
  • テーブルの作成と列の追加は dbDelta()、列の削除や名前変更は存在確認つきの ALTER TABLE で行う
  • 1手順ごとに番号を保存し、ロックで二重実行を防ぎ、どの手順も再実行しても壊れないように書く
  • ステージングで試し、本番の前にバックアップを取る。マルチサイトとアンインストールの扱いも最初に決めておく

お気軽にご相談ください

お見積りへ お問い合わせへ
▲