「古いACF PROを更新できない。この機会に別のプラグインへ移したい。でも、繰り返しフィールドが使えなくなるのは困る」
今回、MOTOKIの企業サイトで実際にこの移行を行いました。
移行先はSmart Custom Fieldsです。
サービス紹介や制作実績で使っていた入力項目を移し、既存データと公開ページの表示を照合してから切り替えています。
結論からいうと、単純なテキスト・画像・繰り返し項目が中心なら、移行できる可能性はあります。
ただし、プラグインを入れ替えるだけでは終わりません。
入力欄の設定、保存されているデータ、表示に使うPHPを、それぞれ確認する必要があります。
前半では移行を検討するときの判断材料と、今回の作業結果を紹介します。
後半の実装編では、繰り返しデータの移し替え、取得コードの変更、確認と復旧の手順まで解説します。
コードは実装編にまとめています。
ACF PROをやめる前に、更新できない理由を確認する
ACFには無料版がありますが、ACF PROは有料版です。
繰り返しフィールドはPROの機能に含まれます。
「無料だから更新できない」という整理ではなく、まず導入経路と更新に使うライセンスの状態を確認してください。
ACF PRO公式案内では、ライセンスと更新について説明されています。
以前の制作会社が導入したもの、テーマに同梱されていたもの、自分で契約したものでは、確認先が異なります。
更新できる状態に整えてACF PROを使い続けることも選択肢です。
今回の企業サイトは、使っている項目が比較的単純でした。
ACF PROの多機能さを必要とする構成ではなかったため、必要な入力機能を残してSmart Custom Fieldsへ移す方針にしました。
移行先はSmart Custom Fields
この記事で扱うのは、inc2734氏が開発するSmart Custom Fieldsです。
同じ「SCF」と略されることがあるSecure Custom Fieldsとは別のプラグインなので、インストール時は正式名称と開発元を確認してください。
Smart Custom Fieldsには、フィールドをグループにして繰り返す機能があります。
今回確認した5.0.8の変更履歴にも修正が記載されています。
ただし、将来の保守や、自分のサイトとの互換性まで保証されるわけではありません。
導入時には配布元の更新履歴と対応環境を確認し、サイトのコピーで動作を確かめます。
どんなサイトなら移行を検討しやすいか
| 現在の使い方 | 移行時に確認すること |
|---|---|
| 会社情報・サービス名などの文字列 | 同じフィールド名、保存値、初期値で扱えるか |
| 画像を1枚選ぶ項目 | 保存されている画像IDと、表示コードが期待する戻り値 |
| 説明と金額など、単純な繰り返し | 行数・順序・空欄を保って保存形式を変換できるか |
| 数値・URLの専用入力欄 | 移行先で同じ入力制限や検証を実現できるか |
| 複雑な条件表示や入れ子の繰り返し | 設定とデータ構造を個別に設計し直す必要がある |
| ACF Blocks・Flexible Content・独自のACF連携 | 本記事の移行例の対象外。表示と編集の仕組みから検討する |
特に、入力画面が似ていることと互換性があることは別です。
移行前と同じ編集操作ができるかまで確認してから判断します。
「フィールド名を同じにする」だけでは移行できない理由
カスタムフィールドの移行には、次の3つの作業があります。
どれか1つだけを変えると、管理画面では見えるのに公開ページに出ない、表示はできるのに保存すると崩れる、といった状態になります。
- 入力設定を移す:ラベル、フィールド名、型、表示先の投稿タイプ、初期値、繰り返し設定を整理する。
- 保存データを移す:旧プラグインの保存形式を調べ、移行先で読める形にする。
- 表示コードを直す:テーマやスニペットで使っている取得処理と出力処理を変更する。
単純な文字列なら既存の投稿メタをそのまま利用できる場合があります。
一方、今回の繰り返しフィールドは、ACFとSmart Custom Fieldsで保存の並べ方が異なっていました。行の内容を別の形式へ複製する作業が必要でした。
ACFの繰り返しフィールド自体は、子フィールドの組を複数行持つ仕組みです。
どのような機能かはACF公式のRepeaterドキュメントでも確認できます。
実際の移行では、使用しているバージョンとサイトの実データを優先して調べます。
今回の移行で引き継いだもの・変わったもの
今回の移行対象は企業サイトのトップWordPressです。
この技術ブログを含む、別に設置したWordPressの移行完了を示すものではありません。
実施時の組み合わせはACF PRO 5.9.1からSmart Custom Fields 5.0.8でした。
| 確認対象 | 作業結果 |
|---|---|
| サービス・制作実績の入力設定 | 2組を作成 |
| カスタムフィールドを持つ投稿 | 68件を照合 |
| 取得したフィールド値 | 272項目を照合 |
| 元の投稿メタデータ | 切り替え直後に1,436行の保持を確認 |
| 本文などの比較 | 95件のスナップショットが一致 |
| サービス一覧・制作実績一覧 | 生成HTMLが変更前と一致 |
| 繰り返しの保存 | 検証コピーで日本語・引用符・バックスラッシュ・金額0を確認 |
数値とURLの入力欄はテキスト型に移しました。
保存済みの値は保持しましたが、数値・URL専用の入力検証までは同じになっていません。
同じ編集仕様が必要なサイトでは、ここを別途実装するか、ACF PROの継続利用も含めて検討します。
また、現在の料金表に使われていた別のJSONデータは変更しませんでした。
入力欄の名前だけで用途を判断せず、公開ページが実際に何を読み込んでいるかを調べたことが、移行範囲を絞るうえで役立ちました。
サーバー容量を圧迫していたのは、画像よりもログだった
移行のきっかけには、サーバーの容量整理もありました。
調べると、企業サイトのデバッグログが約3.3GBまで増え、それを含むバックアップが複数保存されていました。
確認した直近の巨大バックアップでは、約87%がデバッグログでした。
ログの末尾100行を調べた時点では、91行が古いACFに由来する動的プロパティの非推奨メッセージでした。
これは当時のこのサイトでの観測結果であり、ACF全般が重い、Smart Custom Fieldsへ移せば必ず高速になる、という意味ではありません。
今回はプラグインの移行に加えてログとバックアップを整理し、ログの退避とバックアップからの除外も行いました。
整理後のトップWordPressのファイル容量は、バックアップ約463MiBを含めて約928MiBです。データベースとサーバー側の自動バックアップは、この数値に含めていません。
画像やテーマを削除する前に、ディレクトリとファイル単位で容量を調べてください。
古いバックアップは、新しいバックアップを作っただけで必ず消えるものではありません。
保存世代や削除ルールも確認します。
移行は「コピーで検証 → 本番切り替え → 整理」の順で進める
- DB・アップロード画像・テーマ・プラグインを含む復旧用バックアップを確保する。
- 公開されていない検証コピーを作り、フォーム送信・外部連携・定期処理を停止または隔離する。
- フィールド設定とコードの依存箇所を洗い出す。
- 検証コピーで設定とデータを移し、編集・保存・表示を確認する。
- 本番の編集を止めた時間帯に、最新データから同じ移行を実施する。
- キャッシュを消して公開表示を確かめ、復旧資料を残して古いプラグインを整理する。
無料部分で押さえておきたいのは、繰り返し機能がある代替プラグインでも、データの変換とコードの変更が必要になるという点です。
ここからは、単純な「説明+金額」の繰り返しを例に、その作業を具体化します。
【有料パート開始位置・編集用目印】ここから先は、フィールドの対応表、データ変換コード、表示コードの変更例、検証・復旧の手順です。
公開前にこの目印を有料記事の境界へ置き換えてください。
実装編1:使用中のフィールドと取得コードを洗い出す
最初に、ACFの管理画面からフィールドグループをエクスポートし、フィールド名・型・表示条件を控えます。
DBとファイルのバックアップも別に確保します。フィールドグループのエクスポートだけでは投稿に入力した値や画像は復元できません。
以下は対象サイトのWordPressルートで実行する、読み取り用の調査例です。
複数のWordPressがあるサーバーでは、最初にsiteurlを確認します。
WP-CLIとSSHの使い分けは、WordPressとAIの接続方法の記事でも紹介しています。
wp option get siteurl
wp plugin list
# テーマ・MUプラグイン内にあるACFの呼び出しを探す
rg -n 'get_field\s*\(|the_field\s*\(|have_rows\s*\(|get_sub_field\s*\(|acf_' \
wp-content/themes wp-content/mu-pluginsこの検索はファイル内だけが対象です。
Code Snippetsなどに登録したPHPはDBに保存されている場合があるため、管理画面のスニペットも調べます。
独自プラグイン、ACFのブロック、オプションページ、API、フォーム処理も対象です。
今回の企業サイトでは、稼働中のスニペット2件の取得処理を変更しました。
実装編2:Smart Custom Fields側の入力欄を先に作る
まず検証コピーにSmart Custom Fieldsを導入します。
旧ACFを使う画面も確認できる状態で設定を作りますが、移行途中の実投稿を両方の入力欄から更新しないようにします。
保存テストは複製した投稿を使ってください。
本記事のコード例では、投稿タイプがservicesで、ACF側にprice_listという繰り返しフィールドがある構成を想定します。
子フィールドは次の2つだけです。
Smart Custom Fields側では、グループ名をprice_listにして「繰り返し」を有効にし、同じ順序で子フィールドを登録してください。
| 用途 | ACF側の名前・型 | SCF側の名前・型 |
|---|---|---|
| 繰り返しのまとまり | price_list / Repeater | price_list / 繰り返しグループ |
| サービスの説明 | service_fee_note / Text | service_fee_note / テキスト |
| 金額 | service_fee / Number | service_fee / テキスト |
表示条件はservicesに合わせます。
子フィールド名が、同じ投稿に適用される他のグループや独自処理と重複していないことも確認します。
Smart Custom Fieldsの今回の保存形式では、子フィールド名が投稿メタのキーになるためです。
この例は、入れ子のRepeater、Flexible Content、チェックボックスなどの複数選択、画像配列、ユーザー・ターム・オプションのフィールドには対応しません。
以下のコードは、文字列で保存された2項目の繰り返しを1投稿ずつ複製するための限定的な例です。
今回の本番移行スクリプトをそのまま公開したものではありません。
実装編3:ACFとSCFの保存形式を確認する
今回調べたACFでは、親のキーに行数が入り、子のキーには行番号が付いていました。
Smart Custom Fields側は、同じ子フィールド名の投稿メタを複数行保存する形です。
概念的には次のように対応します。
| ACF側の投稿メタ | 値の例 | SCF側の投稿メタ |
|---|---|---|
| price_list | 2 | 行数キーをそのままコピーするのではない |
| price_list_0_service_fee_note | 基本プラン | service_fee_note の1行目 |
| price_list_0_service_fee | 250000 | service_fee の1行目 |
| price_list_1_service_fee_note | 追加料金 | service_fee_note の2行目 |
| price_list_1_service_fee | 0 | service_fee の2行目 |
SCF側の各キーに、全行をまとめた配列を1件保存する方法とは異なります。ここでは、同じキーで複数の投稿メタを追加します。
WordPressのadd_post_meta()には、そのための使い方があります。
また、元のACFデータと、アンダースコアで始まるフィールド参照用のメタは、この段階では削除しません。
後から比較できる状態を残します。
実装編4:繰り返しデータを1投稿ずつ複製する
次のコードをacf-to-smart-repeater.phpとして、Webからアクセスできない作業ディレクトリに保存します。
functions.phpや常時実行されるスニペットには追加しません。
対象サイトのURL、投稿タイプ、フィールド名を自分の構成に合わせてください。
標準の動作はdry-runで、DBを書き換えません。
applyを指定した場合だけ複製します。移行先に同じキーが1つでもあれば停止し、元データが欠けている場合も停止します。
途中失敗時のDBロールバックにはInnoDBが必要です。
// WP-CLI専用。対象サイト・投稿タイプ・フィールド名を確認してから使う。
if (!defined('WP_CLI') || !WP_CLI) { exit; }
global $wpdb;
$expected_site = 'https://example.com'; // 対象サイトの siteurl と完全一致させる
$post_id = absint($args[0] ?? 0);
$mode = $args[1] ?? 'dry-run';
$group = 'price_list';
$keys = ['service_fee_note', 'service_fee'];
if (get_option('siteurl') !== $expected_site) {
WP_CLI::error('対象サイトが一致しません。');
}
if (!$post_id || get_post_type($post_id) !== 'services') {
WP_CLI::error('services の投稿IDを指定してください。');
}
if (!in_array($mode, ['dry-run', 'apply'], true)) {
WP_CLI::error('モードは dry-run または apply です。');
}
// 同名キーが別用途・移行済みで存在する場合も、上書きしない。
foreach ($keys as $key) {
if (metadata_exists('post', $post_id, $key)) {
WP_CLI::error('移行先キーが存在します: ' . $key);
}
}
$count = get_post_meta($post_id, $group, true);
if (!is_string($count) || !preg_match('/^(0|[1-9][0-9]*)$/D', $count)
|| (int) $count > 1000) {
WP_CLI::error('行数が不正、未保存、または上限1000行を超えています。');
}
$columns = array_fill_keys($keys, []);
for ($i = 0; $i < (int) $count; $i++) {
foreach ($keys as $key) {
$source = $group . '_' . $i . '_' . $key;
if (!metadata_exists('post', $post_id, $source)) {
WP_CLI::error('元データのキーがありません: ' . $source);
}
$value = get_post_meta($post_id, $source, true);
if (!is_string($value)) {
WP_CLI::error('この例は文字列で保存された単純なフィールド専用です。');
}
$columns[$key][] = $value; // 空文字も文字列 "0" もそのまま保持
}
}
WP_CLI::log(wp_json_encode([
'post_id' => $post_id, 'rows' => (int) $count,
'target_keys' => $keys, 'mode' => $mode,
], JSON_UNESCAPED_UNICODE));
if ($mode === 'dry-run' || (int) $count === 0) { return; }
$engine = $wpdb->get_var($wpdb->prepare(
'SELECT ENGINE FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$wpdb->postmeta
));
if ($engine !== 'InnoDB') { WP_CLI::error('InnoDBのpostmetaが必要です。'); }
if ($wpdb->query('START TRANSACTION') === false) {
WP_CLI::error('トランザクションを開始できません。');
}
try {
foreach ($columns as $key => $values) {
foreach ($values as $value) {
if (add_post_meta($post_id, $key, wp_slash($value), false) === false) {
throw new RuntimeException('保存に失敗: ' . $key);
}
}
}
wp_cache_delete($post_id, 'post_meta');
foreach ($columns as $key => $values) {
if (get_post_meta($post_id, $key, false) !== $values) {
throw new RuntimeException('保存後の照合に失敗: ' . $key);
}
}
if ($wpdb->query('COMMIT') === false) {
throw new RuntimeException('COMMITに失敗しました。DB状態を確認してください。');
}
} catch (Throwable $e) {
$wpdb->query('ROLLBACK');
wp_cache_delete($post_id, 'post_meta');
WP_CLI::error($e->getMessage());
}
wp_cache_delete($post_id, 'post_meta');
WP_CLI::success('繰り返しデータを複製しました。元のACFデータは残っています。');まず対象の投稿IDを確認して、dry-runを実行します。
123は例です。表示される投稿ID、行数、キーが予定どおりかを確認してからapplyを実行します。
投稿が多い場合も、いきなり全件ループさせず、代表的な数件で保存と表示を確認します。
# 実際のWordPressルート、スクリプトの保存先、投稿IDに置き換える
wp --path=/path/to/wordpress eval-file /private/acf-to-smart-repeater.php 123 dry-run
wp --path=/path/to/wordpress eval-file /private/acf-to-smart-repeater.php 123 applyこのスクリプトはフィールド設定の作成、プラグインの有効化・停止、表示コードの変更を行いません。
非空の移行済み投稿に再実行すると既存キーの検出で止まります。行数0の投稿は書き込みません。
元データに空欄がある場合は空文字として保持しますが、メタの行そのものがない場合は別途確認が必要です。
apply中は対象投稿の編集や、同じメタを書き込む定期処理を止めます。
これは同時実行を完全に制御する汎用移行ツールではありません。
独自の保存フックによる外部送信や、別テーブルへの変更までDBトランザクションで巻き戻せるわけでもありません。
文字列を渡すときはwp_slash()を使っています。
WordPressのメタ保存処理でバックスラッシュが失われることを避けるためです。
単に画面に表示できるかだけでなく、引用符やバックスラッシュを含む値が保存後も一致するかを確認します。
実装編5:取得コードと画像の扱いを変更する
単純なテキスト取得の例です。
記事やテーマで対象となる投稿IDを明示し、出力する場所に合わせてエスケープします。
以下のPHP断片は、PHPが動いている箇所に組み込む例です。
// ACFを使っていた処理
$site_name = get_field('site_name', $post_id);
// Smart Custom Fieldsへ移行した処理
$site_name = SCF::get('site_name', $post_id);
echo esc_html((string) $site_name);これは基本形の比較です。
get_fieldの引数がオプションページを指している場合や、取得後に独自の整形をしている場合は、関数名の一括置換では対応できません。
the_fieldのように直接出力する関数も、値を返すSCF::getと同じ扱いにはできません。
画像は戻り値に注意します。
今回のACF設定では画像配列を使っていた箇所がありましたが、移行後は添付ファイルIDとして扱いました。
画像IDのまま表示するなら、次のようにWordPress標準の画像出力関数へ渡せます。
$image_id = absint(SCF::get('card-image', $post_id));
if ($image_id) {
echo wp_get_attachment_image($image_id, 'medium_large');
}画像配列のURLやaltを直接参照していたコードには修正が必要です。
URLをテキストとして保存する項目は、リンクの出力時にesc_urlを使います。
値を取得するだけで安全なHTMLになるとは考えず、WordPressの出力エスケープに沿って処理します。
実装編6:繰り返し表示では「0」を空欄扱いしない
繰り返しグループは配列として取得して、各行を処理します。
以下は金額を整数円として表示する例です。空欄は表示せず、0円は表示します。
小数や税区分が必要な料金表では、表示仕様に合わせて変更してください。
$rows = SCF::get('price_list', $post_id);
if (is_array($rows)) {
foreach ($rows as $row) {
$note = (string) ($row['service_fee_note'] ?? '');
$fee = (string) ($row['service_fee'] ?? '');
if ($note === '' && $fee === '') {
continue;
}
echo '<p>' . esc_html($note);
if ($fee !== '' && is_numeric($fee)) {
echo ':' . esc_html(number_format((float) $fee, 0)) . '円';
}
echo '</p>';
}
}emptyや引数なしのarray_filterで一律に空判定すると、文字列の「0」も除外されます。
料金表では表示内容が変わってしまうため、空文字と0を区別します。
Smart Custom Fieldsから空の繰り返しグループを取得した際に、空欄の1行が返るケースにも備えます。
なお、この表示コードの数値判定は保存時の入力検証ではありません。
不正な入力を保存前に止めたい場合は別の実装が必要です。
実装編7:取得できた値と、保存し直した値を両方確認する
複製後は別のWP-CLI実行で、SCFが値をどう返すか確認します。
Smart Custom Fieldsとフィールド設定が読み込まれる状態で実行してください。
出力には投稿の入力内容が含まれるため、公開場所へ貼り付ける前に内容を確認します。
wp --path=/path/to/wordpress eval 'echo wp_json_encode(SCF::get("price_list", 123), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);'DB上で値が一致しても、入力設定が違えば編集画面や取得結果は変わります。
次の項目を、公開表示と管理画面の両方で確かめます。
保存テストは、検証コピーのテスト投稿で行います。
| テスト項目 | 見るところ |
|---|---|
| 行数と順番 | 途中の空欄を詰めて別の行と組み合わされていないか |
| 金額0・空欄 | 0が消えていないか、空欄が意図しない0円になっていないか |
| 日本語・引用符・バックスラッシュ | 保存し直した後も文字列が一致するか |
| 画像 | 添付ID、画像サイズ、リンク、代替テキストが正しいか |
| 編集操作 | 追加・並べ替え・削除後に、再読み込みしても同じ内容になるか |
| 初期値 | 未入力投稿と、明示的に空欄にした投稿を区別できているか |
| 本文・タイトル・既存メタ | 移行対象外の内容が変更されていないか |
| 公開ページ | キャッシュを消した状態で、一覧と詳細が正しく表示されるか |
今回の作業では、検証コピーで繰り返しデータの保存と再取得をテストし、本番では入力欄の表示と公開ページを確認しました。
さらに移行前後の生成HTMLを比較しました。
キャッシュされた画面だけを見て完了と判断しないことが大切です。
実装編8:本番の切り替えと、戻せる状態の残し方
本番切り替えでは編集を一時停止し、最新のバックアップを取得してから、検証済みの設定と変換を適用します。
表示コードの変更とACFの停止は、依存関係が途切れる時間を作らないよう、同じ作業時間内に揃えて行います。
ACFに依存する処理が残っていないことを確認してから、旧プラグイン本体を整理します。
移行直後は元のフィールド設定と元メタを残し、変更したPHPやスニペットも復旧用に保管します。
注意したいのは、移行後にSCFで編集した繰り返しデータが、元のACF形式へ自動で反映されるわけではないことです。
編集を再開した後に戻す場合は、その差分の逆変換が必要です。
移行前のDBを丸ごと戻すと、移行後に更新された本文や別の設定も失われます。
今回の企業サイトでは、DB・旧プラグイン・子テーマ・元スニペット・照合用データを公開領域の外へ保管しました。
バックアップは「ある」だけでなく、何をどこまで戻せるかを把握しておきます。
移行後は、ログとバックアップの増え方も確認する
プラグインの切り替え後は、新しいエラーが出ていないかを確認します。
容量が増えていたサイトなら、原因となったログの発生状況と、バックアップの中身も見直します。
今回の移行後の確認範囲では、ACF由来のメッセージと致命的エラーは出ていませんでした。
ログの退避とエクスポートからの除外も追加しています。
ただし、容量整理の効果と、プラグイン自体の表示速度・メモリ使用量は分けて評価する必要があります。
他のサイトへ展開する場合も、最初のサイトのスクリプトをそのまま実行せず、フィールドの型、投稿タイプ、取得コード、保存先をサイトごとに確認します。
繰り返しデータの構造と公開表示を照合できるようにしておくと、次の移行でも判断しやすくなります。



