コンテンツへスキップ

wp search-replace とシリアライズ文字列の罠 — なぜ単純な置換だとデータが壊れるのか

wp search-replace とシリアライズ文字列の罠 — なぜ単純な置換だとデータが壊れるのか

サイトのURLを変えたい、ドメインを移したい、開発用の環境へ本番のデータを持ち込みたい。こういう場面で、データベース内の文字列を一括で置き換える作業が必要になる。WordPress には、そのための WP-CLI コマンド wp search-replace がある。

このコマンドは、SQLの UPDATE ... REPLACE() や、ダンプしたSQLファイルを sed で書き換えるやり方と何が違うのか。答えは、PHPのシリアライズ文字列を壊さずに置換できる点にある。この記事では、その「壊れる仕組み」を PHP の小さな実験で確かめながら整理する。特定の製品の実装ではなく、一般的な仕組みの解説である。

補足: 数値や出力は、2026年10月16日に手元の PHP 8.5 で実行した結果。値の長さは文字列の内容で決まるため、環境によらず同じになる。

シリアライズ文字列とは何か

WordPress は、配列やオブジェクトを1つのデータベース列に保存するとき、PHP の serialize() で文字列に変換する。wp_options のテーマ設定やウィジェット設定、プラグインの設定の多くがこの形で入っている。

実験として、URL を含む小さな配列をシリアライズしてみた。

$opt = ['site' => 'http://old.example.com',
        'menu' => ['home' => 'http://old.example.com/', 'title' => 'Old Site']];
echo serialize($opt);

出力は次のようになる(見やすさのため改行はしていない)。

a:2:{s:4:"site";s:22:"http://old.example.com";s:4:"menu";a:2:{s:4:"home";s:23:"http://old.example.com/";s:5:"title";s:8:"Old Site";}}

注目するのは s:22:"..." の部分である。s は文字列、22 はその文字列のバイト数を表す。つまりシリアライズ形式は、値と一緒に長さを持っている。読み込む側(unserialize())は、この長さを頼りに「ここから22バイトが値」と読み取る。

単純な文字列置換で何が起きるか

old.example.com(15文字)を staging.example.net(19文字)へ、シリアライズされた文字列に対して単純に置換してみた。

$naive = str_replace('old.example.com', 'staging.example.net', serialize($opt));
var_dump(@unserialize($naive));

結果は bool(false)、つまり読み込みに失敗した。置換後の文字列を見ると、値は新しいURLに変わっているのに、長さの宣言は s:22 のままだった。実際の値は26バイトになっているのに「22バイト」と書かれているため、unserialize() は形式が壊れていると判断する。

困るのは、置換自体はエラーにならず成功したように見える点である。SQLは正常に完了し、件数も報告される。壊れていると分かるのは、そのオプションを使う画面を開いたときで、設定が初期値に戻る、ウィジェットが消える、といった形で現れることがある。

長さを数え直せば壊れない

正しい手順は、いったん配列に戻し、その中の文字列を置換してから、もう一度シリアライズすることである。

function rep($v) {
  if (is_string($v)) return str_replace('old.example.com', 'staging.example.net', $v);
  if (is_array($v))  return array_map('rep', $v);
  return $v;
}
$ok = serialize(rep(unserialize(serialize($opt))));

この結果は次のとおりで、長さの宣言が s:26 と s:27 に更新されていた。unserialize() も成功し、置換後の配列と一致することを確認できた。

a:2:{s:4:"site";s:26:"http://staging.example.net";s:4:"menu";a:2:{s:4:"home";s:27:"http://staging.example.net/";s:5:"title";s:8:"Old Site";}}

wp search-replace は、列の値がシリアライズ文字列かどうかを判定し、該当すれば読み込み → 置換 → 再シリアライズを行う。だから、長さが変わる置換でも壊れにくい。

例外: 長さが同じなら単純置換でも通る

実験では、たまたま同じ長さの置換も試した。old.example.com と new.example.xyz はどちらも15文字で、この場合は単純置換でも unserialize() は成功した。長さの宣言が変わらないからである。

ただし、これは偶然に頼った状態である。ドメインの長さが揃っているうちは問題が表に出ず、あるとき長さの違う名前へ移した瞬間に壊れる。「前は大丈夫だった」は根拠にならない。

日本語を含む場合: 数えるのは文字ではなくバイト

シリアライズの長さはバイト数である。実験で 'あいう' をシリアライズすると s:9:"あいう" になった。3文字だが、UTF-8 では1文字3バイトなので9になる。

SQLダンプを手作業で書き換えるとき、文字数で数えると合わなくなる。この点でも、長さの管理を人手で行うのは向かない。

JSONの場合は別の注意点がある

ブロックエディターの設定など、JSON 形式で保存されている値もある。JSON はシリアライズのような長さの宣言を持たないため、長さの問題は起きない。代わりに、/ が \/ のようにエスケープされて保存されることがある。実験でも http://old.example.com/ を json_encode すると http:\/\/old.example.com\/ になった。

この形のまま保存されている場合、http://old.example.com/ を検索しても一致しない。置換漏れの原因になるので、結果の件数を見て、想定より少なければエスケープ後の形でも検索してみる。

実務で守りたい順序

  1. まずバックアップを取る。置換はデータベースを直接書き換える。戻せる状態にしてから始める(mysqldump などでデータベースのダンプを取っておく)。
  2. --dry-run で件数を確認する。何も書き換えずに、どのテーブルのどの列に何件ヒットするかを表示できる。
  3. 対象の範囲を絞る。全テーブルが必要でなければ、テーブルを指定する。guid 列のように、基本的に書き換えない列もある。
  4. 本実行後に、画面で確認する。テーマの設定画面・ウィジェット・メニューなど、シリアライズ値を使う箇所を実際に開く。
  5. 件数が0件、または想定より極端に少ない場合は疑う。大文字小文字、http/https の違い、末尾のスラッシュ、JSON のエスケープなど、検索文字列の形が合っていない可能性がある。

コマンドの形は次のようになる。

# まず確認だけ(何も書き換えない)
wp search-replace 'old.example.com' 'staging.example.net' --dry-run

# 問題なければ実行(件数の報告を確認)
wp search-replace 'old.example.com' 'staging.example.net'

まとめ

  • WordPress の設定の多くは PHP のシリアライズ文字列で保存され、値と一緒にバイト数の宣言を持つ
  • 長さの変わる単純な文字列置換は、宣言とのずれを生み、unserialize() が失敗する。しかも置換自体は成功したように見える
  • wp search-replace は、読み込み → 置換 → 再シリアライズを行うため、この問題を避けられる
  • 同じ長さの置換が通ったのは偶然で、頼れない。日本語は文字数でなくバイト数で数える
  • 実行前のバックアップと --dry-run、実行後の画面確認を手順に入れておく

置換のようにデータを直接書き換える作業は、「もう一度実行しても同じ結果になるか」を意識しておくと安全である。この考え方は、冪等性(idempotency)とはでも整理している。