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/ を検索しても一致しない。置換漏れの原因になるので、結果の件数を見て、想定より少なければエスケープ後の形でも検索してみる。
実務で守りたい順序
- まずバックアップを取る。置換はデータベースを直接書き換える。戻せる状態にしてから始める(
mysqldumpなどでデータベースのダンプを取っておく)。 --dry-runで件数を確認する。何も書き換えずに、どのテーブルのどの列に何件ヒットするかを表示できる。- 対象の範囲を絞る。全テーブルが必要でなければ、テーブルを指定する。
guid列のように、基本的に書き換えない列もある。 - 本実行後に、画面で確認する。テーマの設定画面・ウィジェット・メニューなど、シリアライズ値を使う箇所を実際に開く。
- 件数が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)とはでも整理している。