テクニック

WordPressカスタムフィールドの表示|テンプレート出力と空の分岐の書き方

この記事の対象: クラシックテーマ(PHPテンプレート)でカスタムフィールドを扱うWeb制作者

カスタムフィールドは、入れるところまでは簡単です。詰まるのはその先で、テンプレートに出したときに「値が入っていない投稿だけ見出しが浮く」「繰り返しフィールドがうまく回らない」「HTMLタグがそのまま表示される」あたりで手が止まります。

この記事は、値の取り出しから空のときの分岐、繰り返しフィールドの出力まで、そのままコピーして動くPHPを並べます。あわせて、運用する人が迷わず入力できるフィールド設計の考え方も置きました。作業しながら上から順に使える構成にしています。


前提と、この記事が想定する環境

前提と、この記事が想定する環境

クラシックテーマのPHPテンプレートが対象

この記事のコードは、クラシックテーマ(single.phppage.php などのPHPテンプレートを持つテーマ) を前提にしています。ブロックテーマ(templates/*.html でテンプレートを管理するテーマ)では、同じことをブロックバインディングやブロック開発で行うため書き方が変わります。

判定方法はシンプルです。管理画面の「外観」メニューに 「テーマファイルエディター」 があり、「エディター(サイトエディター)」がなければクラシックテーマです。あるいはテーマフォルダ直下に templates/index.html があればブロックテーマ、index.php だけならクラシックテーマと考えて構いません。

必要な環境と権限

項目 必要なもの
権限 管理者(manage_options)。テーマファイルを編集するため
WordPress 5.x以降を想定
PHP 7.4以降。それ以前のバージョンでは ?? 演算子など一部の書き方が通りません
編集方法 FTP/SFTPまたはローカル環境。管理画面のテーマファイルエディターは白画面事故が起きるため非推奨
テーマ 子テーマ。親テーマを直接編集すると更新で消えます

親テーマを直接触らない話は、テーマの選び方そのものとつながっています。自作か既存かで、この後の改修コストが変わります。

作業前にバックアップを取る

テンプレートを触る前に、最低でも編集対象ファイルのコピーを手元に残してください。single.php を編集するなら single.php.bak を同じ階層に置いておけば、白画面になってもFTPで戻せます。サイト全体のバックアップ体制がまだなら、先に整えてから作業したほうが安全です。


カスタムフィールドの値をテンプレートに出す基本

カスタムフィールドの値をテンプレートに出す基本

get_post_meta() で取り出す

WordPress標準のカスタムフィールドは、get_post_meta() で取り出します。ACF(Advanced Custom Fields)を使っている場合も、内部的には同じ wp_postmeta テーブルに保存されているため、この関数で読めます(ACF固有の型は後述)。

引数は3つです。

get_post_meta( 投稿ID, 'フィールド名', true );

第3引数の true は「単一の値を返す」という意味です。ここを false にすると配列で返るため、通常は true を指定します。この違いが、後述する繰り返しフィールドの扱いに効いてきます。

そのまま動く最小のコード

single.php のループ内、本文を出力している the_content() の直後に置く形です。

ファイル: wp-content/themes/子テーマ名/single.php

<?php
// カスタムフィールド「company_name」を取り出して表示する
$company_name = get_post_meta( get_the_ID(), 'company_name', true );

if ( $company_name !== '' ) : ?>
    <div class="entry-meta-field">
        <h2 class="entry-meta-field__title">会社名</h2>
        <p class="entry-meta-field__value"><?php echo esc_html( $company_name ); ?></p>
    </div>
<?php endif; ?>

変更する箇所は3つです。

箇所 変える内容
'company_name' 実際のフィールド名(メタキー)に
会社名 画面に出したい見出しラベルに
entry-meta-field テーマのCSS設計に合わせたクラス名に

echo の前に必ずエスケープする

echo esc_html( $company_name )esc_html() は省略できません。カスタムフィールドの値は入力者が自由に書けるため、<script> を入れられればそのまま実行されます。用途ごとに関数を使い分けます。

出力先 使う関数
テキスト(本文・見出し) esc_html() <p><?php echo esc_html( $v ); ?></p>
属性値(alt・title・data-*) esc_attr() <img alt="<?php echo esc_attr( $v ); ?>">
URL(href・src) esc_url() <a href="<?php echo esc_url( $v ); ?>">
HTMLを許可したい本文 wp_kses_post() <?php echo wp_kses_post( $v ); ?>

「HTMLタグが &lt;br&gt; のようにそのまま表示される」という症状は、esc_html() を使うべきでない箇所に使っているサインです。入力者にHTMLを書かせる欄なら、esc_html() ではなく wp_kses_post() を通します。許可タグの範囲は投稿本文と同じなので、<strong><a> は生き、<script> は落ちます。

改行を含むテキストエリアなら、次の書き方が実務では扱いやすいはずです。

<?php
$description = get_post_meta( get_the_ID(), 'description', true );
if ( $description !== '' ) {
    // 改行を <p> と <br> に変換したうえでエスケープする
    echo wpautop( esc_html( $description ) );
}
?>

wpautop() は改行を <p><br> に変換する関数です。esc_html() を先に通してから wpautop() に渡す順序が重要で、逆にすると変換した <p> タグまでエスケープされて画面に文字として出ます。

なお、the_content() のようにフィルターを通したい場合は apply_filters( 'the_content', $value ) という手もありますが、他プラグインのフィルターが全部乗るため、カスタムフィールドの短いテキストには重すぎます。単純な改行整形なら wpautop() で十分です。


空のときに見出しごと消す分岐の書き方

空のときに見出しごと消す分岐の書き方

「値がある」の判定を間違えない

一番よくある事故が、empty() を使ったせいで 0 が消えるというものです。

// これは危険。値が "0" のときも空扱いになる
if ( ! empty( $price ) ) { ... }

PHPでは "0"00.0、空配列などがすべて empty() で真になります。在庫数、階数、割引率など、0 が意味を持つフィールドでは表示が消えてしまいます。

未入力かどうかを見たいだけなら、比較対象を絞ります。

// 未入力(空文字)だけを弾く
if ( $price !== '' ) { ... }

get_post_meta() は、値が存在しない場合に第3引数 true なら空文字列 '' を返します(falsenull ではありません)。ここを押さえておくと分岐が安定します。

空判定に使う関数の使い分け

$value !== ” で判定

  • 0 や “0” が消えない
  • 未入力だけを正確に弾ける
  • 数値・価格・件数に安全

empty() で判定

  • 0 も空扱いになる
  • 空配列を弾きたいときだけ有効
  • 数値フィールドでは事故る

数値を扱うフィールドがひとつでもあるなら、テンプレート全体を !== '' に統一しておくほうが、後から入るメンバーの事故を減らせます。「このフィールドは0が入らないから empty() でいい」という例外を1か所でも作ると、コピーされて広がります。

見出しごと消すテンプレート

カスタムフィールドの表示でみっともなくなるのは、ラベルだけ残って値が空という状態です。<h2>会社名</h2> の下に何もない、という見た目になります。

出力ブロック全体を条件の内側に入れます。

ファイル: wp-content/themes/子テーマ名/single.php

<?php
$company_name = get_post_meta( get_the_ID(), 'company_name', true );
$founded_year = get_post_meta( get_the_ID(), 'founded_year', true );
$official_url = get_post_meta( get_the_ID(), 'official_url', true );

// 3つのうちひとつでも入力があるときだけ、セクションごと出す
if ( $company_name !== '' || $founded_year !== '' || $official_url !== '' ) : ?>
<section class="profile-box">
    <h2 class="profile-box__title">基本情報</h2>
    <dl class="profile-box__list">

        <?php if ( $company_name !== '' ) : ?>
            <dt>会社名</dt>
            <dd><?php echo esc_html( $company_name ); ?></dd>
        <?php endif; ?>

        <?php if ( $founded_year !== '' ) : ?>
            <dt>設立年</dt>
            <dd><?php echo esc_html( $founded_year ); ?>年</dd>
        <?php endif; ?>

        <?php if ( $official_url !== '' ) : ?>
            <dt>公式サイト</dt>
            <dd><a href="<?php echo esc_url( $official_url ); ?>" target="_blank" rel="noopener">
                <?php echo esc_html( $official_url ); ?>
            </a></dd>
        <?php endif; ?>

    </dl>
</section>
<?php endif; ?>

変更する箇所は、3つのフィールド名(company_name / founded_year / official_url)、ラベル文言、クラス名(profile-box)です。フィールドが増えるときは、$変数 = get_post_meta(...) の行と if ブロックをセットで足し、外側の || 条件にも追加します。

外側の条件を書き忘れると、全部未入力の投稿で <h2>基本情報</h2> と空の <dl> だけが残ります。ここが抜けやすい箇所です。

フィールドが増えたら配列でまとめる

項目が5つを超えると、上の書き方は縦に伸びて管理しづらくなります。定義を配列にまとめると、追加が1行で済みます。

ファイル: wp-content/themes/子テーマ名/single.php

<?php
// [ メタキー => ラベル ] の順で定義する。表示順もこの並び順になる
$profile_fields = array(
    'company_name' => '会社名',
    'founded_year' => '設立年',
    'employees'    => '従業員数',
    'address'      => '所在地',
    'business'     => '事業内容',
);

// 入力済みのものだけ集める
$profile_rows = array();
foreach ( $profile_fields as $meta_key => $label ) {
    $value = get_post_meta( get_the_ID(), $meta_key, true );
    if ( $value !== '' ) {
        $profile_rows[] = array( 'label' => $label, 'value' => $value );
    }
}

// 1件でもあればセクションを出す
if ( ! empty( $profile_rows ) ) : ?>
<section class="profile-box">
    <h2 class="profile-box__title">基本情報</h2>
    <dl class="profile-box__list">
        <?php foreach ( $profile_rows as $row ) : ?>
            <dt><?php echo esc_html( $row['label'] ); ?></dt>
            <dd><?php echo esc_html( $row['value'] ); ?></dd>
        <?php endforeach; ?>
    </dl>
</section>
<?php endif; ?>

変更する箇所$profile_fields の中身だけです。項目を足すときはこの配列に1行加えれば、判定も出力も自動でついてきます。

集めた結果を array( 'label' => ..., 'value' => ... ) の連番配列にしているのは、ラベルをキーにすると同じラベルの項目を2つ持てなくなるためです。「担当者」が2行あるような設計でも、この形なら壊れません。

ここで empty( $profile_rows ) を使っているのは、対象が配列だからです。個別の値の判定には !== '' を使い、配列の件数判定には empty() を使う、と役割を分けておくと混乱しません。

単位や記号は出力側で足す

上のコードで設立年に「年」を足しているように、単位はテンプレート側に持たせます。入力側で「2018年」と書かせると、並び替えや絞り込みに使えなくなるためです。

<?php if ( $price !== '' ) : ?>
    <dd><?php echo esc_html( number_format( (int) $price ) ); ?>円</dd>
<?php endif; ?>

number_format() で3桁区切りを付けるのも出力側の仕事です。(int) でキャストしているのは、入力者が全角数字やカンマ付きで入れた場合に警告が出ないようにするためですが、そもそも入力段階で弾けるならそのほうが確実です。この話は後半の「入力欄に説明文を付ける」につながります。


繰り返しフィールドの扱い

繰り返しフィールドの扱い

繰り返し(リピーター)フィールドの実装は、大きく2通りあります。ACFのリピーターを使う方法と、標準機能だけで同名メタキーを複数持つ方法です。

繰り返しフィールドの2つの実装方法

ACFリピーター(有料機能)

  • 入力画面が行単位で分かりやすい
  • 1行に複数項目を持てる
  • have_rows() で回す

同名メタキーを複数保存

  • 標準関数だけで完結する
  • 1項目の繰り返しに向く
  • get_post_meta の第3引数 false

どちらを選ぶかは「1行に複数の項目が必要か」で決まります。URLだけ、画像だけ、といった単一項目の繰り返しなら、標準機能で十分です。逆に「名前・役職・写真」を1行として扱いたいなら、標準機能でやると保存も並び順の維持も自前で書くことになるため、ACFのリピーターを選んだほうが早く終わります。

標準機能だけで繰り返しを出す

同じメタキーで複数の値を保存している場合、get_post_meta() の第3引数を false にすると配列で返ります。

ファイル: wp-content/themes/子テーマ名/single.php

<?php
// 第3引数を false にすると、同名メタキーの値がすべて配列で返る
$award_list = get_post_meta( get_the_ID(), 'award', false );

// 配列で、かつ1件以上あるときだけ出す
if ( is_array( $award_list ) && count( $award_list ) > 0 ) : ?>
<section class="award-list">
    <h2 class="award-list__title">受賞歴</h2>
    <ul class="award-list__items">
        <?php foreach ( $award_list as $award ) : ?>
            <?php if ( $award === '' ) { continue; } // 空の行はスキップ ?>
            <li class="award-list__item"><?php echo esc_html( $award ); ?></li>
        <?php endforeach; ?>
    </ul>
</section>
<?php endif; ?>

変更する箇所はメタキー 'award'、見出し文言、クラス名です。

途中に continue を入れているのは、入力途中で空行が保存されることが実際にあるためです。空の <li> が並ぶと、リストの余白だけ増えて崩れて見えます。

この書き方には弱点がひとつあって、全行が空のときは見出しが残りますcount() は空文字の行も1件として数えるためです。厳密に消したいなら、先に空を除いてから件数を見ます。

<?php
$award_list = get_post_meta( get_the_ID(), 'award', false );

// 空文字を除いてから件数を判定する
$award_list = array_filter( (array) $award_list, function ( $v ) {
    return $v !== '';
} );

if ( count( $award_list ) > 0 ) {
    // ここで出力する
}
?>

array_filter() を引数なしで呼ぶと empty() と同じ判定になり、"0" の行まで落ちます。無名関数で !== '' を明示しているのはそのためです。ここでも判定基準は同じです。

ACFのリピーターフィールドを出す

ACFのリピーターは have_rows() / the_row() / get_sub_field() の組み合わせで回します。この3つはACFが有効化されている環境でのみ存在するため、関数の存在確認を先に入れておくと、プラグインを止めたときの致命的エラーを避けられます。

ファイル: wp-content/themes/子テーマ名/single.php

<?php
// ACFが無効なときにテンプレートを落とさないためのガード
if ( function_exists( 'have_rows' ) && have_rows( 'staff_members' ) ) : ?>
<section class="staff-list">
    <h2 class="staff-list__title">スタッフ紹介</h2>
    <div class="staff-list__grid">

    <?php while ( have_rows( 'staff_members' ) ) : the_row();
        $staff_name  = get_sub_field( 'staff_name' );
        $staff_role  = get_sub_field( 'staff_role' );
        $staff_photo = get_sub_field( 'staff_photo' ); // 返り値の形式は「画像URL」に設定

        // 名前が空の行は出力しない
        if ( $staff_name === '' || $staff_name === null ) { continue; }
    ?>
        <article class="staff-card">
            <?php if ( $staff_photo ) : ?>
                <img class="staff-card__photo"
                     src="<?php echo esc_url( $staff_photo ); ?>"
                     alt="<?php echo esc_attr( $staff_name ); ?>"
                     width="320" height="320" loading="lazy">
            <?php endif; ?>

            <h3 class="staff-card__name"><?php echo esc_html( $staff_name ); ?></h3>

            <?php if ( $staff_role !== '' && $staff_role !== null ) : ?>
                <p class="staff-card__role"><?php echo esc_html( $staff_role ); ?></p>
            <?php endif; ?>
        </article>
    <?php endwhile; ?>

    </div>
</section>
<?php endif; ?>

変更する箇所は5つです。

箇所 変える内容
'staff_members' リピーターフィールド名
'staff_name' など サブフィールド名
width="320" height="320" 実際に表示するサイズ
staff-list / staff-card クラス名
見出し文言 「スタッフ紹介」を実際のラベルに

get_sub_field() は値がないとき nullfalse を返すことがあるため、!== '' だけでなく !== null も見ています。ACF側の返り値設定によって型が変わるので、迷ったら開発中に var_dump( get_sub_field( 'staff_photo' ) ); を一時的に入れて実際の型を確認してください。

画像フィールドの返り値を「画像配列」に設定している場合は、$staff_photo['url']$staff_photo['alt'] で取り出します。altに入力値を使えるぶん配列のほうが親切ですが、テンプレート側の分岐は増えます。どちらでも構いませんが、プロジェクト内で片方に統一してください。フィールドごとに設定が違うと、コピーしたコードが動かない原因になります。

画像に widthheight を明示しているのは、レイアウトシフト(CLS)を防ぐためです。loading="lazy" はファーストビューより下の画像にだけ付けます。ファーストビューの画像に付けると、かえって表示開始が遅くなります。

件数を制限して出す

「最新3件だけ」のような制限は、array_slice() で切ります。

<?php
$award_list = get_post_meta( get_the_ID(), 'award', false );

if ( is_array( $award_list ) && count( $award_list ) > 0 ) {
    // 先頭から3件だけ取り出す。件数を変えるのはこの数字
    $award_list = array_slice( $award_list, 0, 3 );

    echo '<ul class="award-list__items">';
    foreach ( $award_list as $award ) {
        if ( $award === '' ) { continue; }
        echo '<li>' . esc_html( $award ) . '</li>';
    }
    echo '</ul>';
}
?>

array_slice( $配列, 0, 3 )3 が件数です。ここだけ変えれば表示件数が変わります。

ACFのリピーターで同じことをするなら、カウンタを持たせて break します。

<?php
$row_count = 0;
if ( function_exists( 'have_rows' ) && have_rows( 'staff_members' ) ) {
    while ( have_rows( 'staff_members' ) ) {
        the_row();
        if ( $row_count >= 3 ) { break; }
        $row_count++;
        // 出力処理
    }
}
?>

新しい順に3件出したい場合は、入力側の並び順をそのまま信じないほうが安全です。運用者が行を並べ替えるとは限らないので、日付のサブフィールドを持たせて並べ替えるか、そもそも「上から3件を出します」と入力画面の説明に書いておきます。


一覧ページとアーカイブで使うとき

一覧ページとアーカイブで使うとき

ループの中では get_the_ID() を使う

アーカイブや WP_Query のループ内では、投稿IDが1件ずつ変わります。ここで固定のIDを書いてしまうと、全件が同じ値になります。

ファイル: wp-content/themes/子テーマ名/archive.php

<?php if ( have_posts() ) : ?>
<div class="post-grid">
    <?php while ( have_posts() ) : the_post();
        // ループ内では毎回 get_the_ID() で現在の投稿IDを取る
        $subtitle = get_post_meta( get_the_ID(), 'subtitle', true );
    ?>
    <article class="post-card">
        <a class="post-card__link" href="<?php the_permalink(); ?>">
            <h2 class="post-card__title"><?php the_title(); ?></h2>

            <?php if ( $subtitle !== '' ) : ?>
                <p class="post-card__subtitle"><?php echo esc_html( $subtitle ); ?></p>
            <?php endif; ?>
        </a>
    </article>
    <?php endwhile; ?>
</div>
<?php endif; ?>

get_post_meta() はループ内で呼んでも、WordPressが投稿メタをまとめてキャッシュしているため追加のクエリは発生しません(update_post_meta_cache が既定で有効な通常のループの場合)。1件ずつ呼ぶことを過度に恐れる必要はありません。

ただし WP_Query'update_post_meta_cache' => false を渡している場合はキャッシュが効かず、件数分のクエリが走ります。表示が重いときは、この指定が入っていないか確認してください。

カスタムフィールドの値で並び替える

数値フィールドで並び替えるときは、meta_value_num を使います。文字列として並べる meta_value だと「10」が「2」より前に来ます。

ファイル: wp-content/themes/子テーマ名/archive.php(またはページテンプレート)

<?php
$sorted_query = new WP_Query( array(
    'post_type'      => 'post',        // 対象の投稿タイプに変更する
    'posts_per_page' => 10,            // 表示件数
    'meta_key'       => 'priority',    // 並び替えに使うフィールド名
    'orderby'        => 'meta_value_num',
    'order'          => 'DESC',
    // フィールドが未入力の投稿を除外したいときは下の meta_query を有効にする
    // 'meta_query'  => array( array( 'key' => 'priority', 'compare' => 'EXISTS' ) ),
) );

if ( $sorted_query->have_posts() ) :
    while ( $sorted_query->have_posts() ) : $sorted_query->the_post(); ?>
        <article class="post-card">
            <h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
        </article>
    <?php endwhile;
    wp_reset_postdata(); // これを忘れると以降のループが壊れる
endif;
?>

変更する箇所post_typeposts_per_pagemeta_key の3つです。

wp_reset_postdata() は必須です。これを書かないと、このループ以降のテンプレート(サイドバーやフッターの投稿表示)が最後の投稿の情報を引きずります。

meta_key を指定した並び替えでは、そのメタキーを持たない投稿が一覧から落ちます。「並べ替えたら件数が減った」という相談はたいていこれです。未入力の投稿も出したいなら、meta_queryEXISTSNOT EXISTS の2グループを relation => OR で組むか、保存時に既定値を入れる設計にします。後者のほうがクエリが素直になるので、新規案件なら保存時に埋める設計を勧めます。

なお、meta_key を使った並び替えは投稿数が増えるとクエリが重くなります。wp_postmeta を結合してソートする形になるため、投稿数とメタの件数の両方が効いてきます。表示速度が気になり始めたら、体感ではなく計測して原因を切り分けるところから始めるのが確実です。


更新する人が入力しやすいフィールド設計

更新する人が入力しやすいフィールド設計

コードが動くことと、納品後に運用が回ることは別の話です。ここを設計しておかないと、公開後に「入力してもらえないフィールド」が残ります。

未入力を前提に設計する

実務で確実に言えるのは、すべてのフィールドが埋まることはないということです。埋まっていない前提で組んでおけば、後から表示が崩れません。

フィールド設計から実装までの手順
必須と任意を分ける必須は3つまでに絞る
空のときの見え方を決める消すか、代替を出すか
入力例を説明文に書く単位・形式まで明示する
空データでテストする全項目未入力の投稿を1件作る

最後の「空データでテスト」を工程に入れておくと、公開後の崩れがほぼ消えます。全項目未入力の下書き投稿を1件作り、プレビューで確認する。それだけです。余裕があれば「1項目だけ入力した投稿」も作ると、外側の if の書き忘れが一度で見つかります。

空のときに代替を出すパターン

消すのではなく、既定値を出したいケースもあります。サムネイル画像などがこれにあたります。

<?php
$thumb_url = get_post_meta( get_the_ID(), 'card_image', true );

// カスタムフィールド → アイキャッチ → 既定画像 の順に探す
if ( $thumb_url === '' ) {
    $thumb_url = get_the_post_thumbnail_url( get_the_ID(), 'large' );
}
if ( ! $thumb_url ) {
    // 既定画像のパス。テーマの images フォルダに置いた画像に変更する
    $thumb_url = get_stylesheet_directory_uri() . '/images/noimage.jpg';
}
?>
<img class="post-card__image"
     src="<?php echo esc_url( $thumb_url ); ?>"
     alt="<?php echo esc_attr( get_the_title() ); ?>"
     width="800" height="450" loading="lazy">

変更する箇所は、メタキー 'card_image'、既定画像のパス /images/noimage.jpgwidth / height です。

get_the_post_thumbnail_url() はアイキャッチがないとき false を返すため、2つめの判定は ! $thumb_url にしています。返り値の型が関数ごとに違う点は、この記事で何度も出てくる落とし穴です。

「消す」か「代替を出す」かの判断は、その要素がレイアウトの骨格に関わるかどうかで決めます。カード一覧の画像のように、無いとカードの高さが揃わなくなるものは代替を出す。会社概要の1行のように、無くても他の行が詰まるだけのものは消す。この基準を最初に決めておくと、フィールドが増えても迷いません。

入力欄に説明文を付ける

ACFなら「フィールドの説明」欄、標準のメタボックスなら <p class="description"> を添えます。書くべきは単位と形式です。

悪い説明 良い説明
「価格」 「価格(半角数字のみ。円マーク・カンマは不要)」
「設立年」 「設立年(西暦4桁。例: 2018)」
「URL」 「公式サイトURL(https:// から入力)」
「画像」 「画像(横800px以上推奨。横長で切り抜かれます)」

単位や記号をテンプレート側で付ける設計にしておけば、入力者は数字だけ入れればよくなります。<?php echo esc_html( $price ); ?>円 のように出力側に単位を持たせる、という判断です。

標準のカスタムフィールドで説明を出すなら、メタボックスを自作します。

ファイル: wp-content/themes/子テーマ名/functions.php

<?php
/**
 * 投稿編集画面に「基本情報」メタボックスを追加する
 */
function mytheme_add_profile_metabox() {
    add_meta_box(
        'mytheme_profile',      // ボックスのID
        '基本情報',              // 画面に出る見出し
        'mytheme_render_profile_metabox',
        'post',                 // 対象の投稿タイプ
        'normal',
        'default'
    );
}
add_action( 'add_meta_boxes', 'mytheme_add_profile_metabox' );

function mytheme_render_profile_metabox( $post ) {
    wp_nonce_field( 'mytheme_profile_save', 'mytheme_profile_nonce' );

    $founded_year = get_post_meta( $post->ID, 'founded_year', true );
    ?>
    <p>
        <label for="mytheme_founded_year"><strong>設立年</strong></label><br>
        <input type="text" id="mytheme_founded_year" name="founded_year"
               value="<?php echo esc_attr( $founded_year ); ?>"
               class="regular-text" inputmode="numeric">
        <span class="description">西暦4桁の半角数字で入力してください(例: 2018)</span>
    </p>
    <?php
}

/**
 * 保存処理
 */
function mytheme_save_profile_metabox( $post_id ) {
    // 自動保存では処理しない
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }
    // nonce検証
    if ( ! isset( $_POST['mytheme_profile_nonce'] )
        || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['mytheme_profile_nonce'] ) ), 'mytheme_profile_save' ) ) {
        return;
    }
    // 権限確認
    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return;
    }

    if ( isset( $_POST['founded_year'] ) ) {
        $value = sanitize_text_field( wp_unslash( $_POST['founded_year'] ) );
        if ( $value === '' ) {
            delete_post_meta( $post_id, 'founded_year' );
        } else {
            update_post_meta( $post_id, 'founded_year', $value );
        }
    }
}
add_action( 'save_post', 'mytheme_save_profile_metabox' );

変更する箇所は、接頭辞 mytheme_(テーマ名に合わせる)、フィールド名 founded_year、対象投稿タイプ 'post'、ラベルと説明文です。

nonce検証と権限確認は削らないでください。これがないと、外部から任意の投稿メタを書き換えられる経路になります。空文字のとき delete_post_meta() でレコードごと消しているのは、wp_postmeta に空の行を溜めないためです。

ブロックエディタ(Gutenberg)を使っているサイトでこのメタボックスを表示したい場合は、add_meta_box() の第7引数以降ではなく、投稿タイプ登録時に 'show_in_rest' => true を付けたうえで、必要なら register_post_meta() でメタも登録します。クラシックなメタボックスはブロックエディタでも下部に表示されますが、REST API経由の保存では save_post が期待どおり動かないことがあるため、そこまで作り込むならACFに寄せたほうが早いという判断もあります。

外部ライブラリを追加せず標準関数だけで完結しているので、フロント側の読み込みは増えません。カスタムフィールド用のプラグインを1つ入れるかどうかは、こうした管理画面の作り込みをどこまでやるかで判断が分かれます。項目が5つ以下で入力者が制作者自身なら標準機能、項目が多くクライアントが毎週触るならACF、というのが実務での分かれ目です。


うまくいかないとき

うまくいかないとき

値が何も表示されない

上から順に切り分けます。

  1. メタキーが合っているか。テンプレート内で var_dump( get_post_meta( get_the_ID(), '', false ) ); を実行すると、その投稿が持つ全メタキーと値が出ます。ここに目的のキーがなければ、名前の綴りか保存自体の問題です。
  2. ループの中にいるかget_the_ID() はループ外では false を返します。ヘッダーやフッターで使うなら、get_queried_object_id() に置き換えます。
  3. ACFの返り値設定。ACFの画像・リンク・投稿オブジェクトは、フィールド設定の「返り値の形式」で配列かURLか変わります。var_dump() で実際の型を確認してください。
  4. ACFのフィールドグループの表示条件。フィールドグループが対象の投稿タイプやテンプレートに割り当てられていないと、入力欄自体が出ません。入力したつもりが別の投稿タイプで入力していた、というのも起きます。

アンダースコアで始まるフィールドが見えない

_price のようにアンダースコアで始まるメタキーは、WordPressが非表示メタ(protected meta)として扱い、管理画面のカスタムフィールド欄に出しません。get_post_meta() では通常どおり取れるので、テンプレート側は問題ありません。管理画面で編集させたいなら、アンダースコアなしの名前に変えるか、上のメタボックスのように編集UIを自作します。

ACFは内部的にこの仕組みを使っていて、値の本体と _フィールド名 のキー参照をペアで保存します。wp_postmeta を直接見たときに同じ名前が2行あるのはそのためで、消してはいけません。

白い画面になった

PHPの構文エラーです。ifendifwhileendwhile の対応が崩れているケースがほとんどです。バックアップから戻したうえで、wp-config.php に一時的に次を追加すると原因行が表示されます。

define( 'WP_DEBUG', true );
define( 'WP_DEBUG_DISPLAY', true );

原因を特定したら必ず false に戻してください。 本番でエラーを表示したままにすると、サーバーの内部パスが外部に見えます。

見出しだけ残る

外側の if を書き忘れています。「見出し・囲みタグ・値」をひとまとまりとして条件の内側に入れているか確認します。この記事の「見出しごと消すテンプレート」の形が基本形です。

数値の 0 が消える

empty() を使っています。!== '' に置き換えます。この記事で挙げた中で、公開後に一番気づかれにくい不具合です。

本番に反映されない

キャッシュ系プラグインかサーバー側のキャッシュが残っています。テンプレートを変更したら、キャッシュを削除してから確認します。ブラウザのスーパーリロード(Ctrl+Shift+R / Cmd+Shift+R)だけでは、サーバー側キャッシュは消えません。

サーバーによってキャッシュの仕組みも管理画面の場所も違うため、納品先の環境は事前に把握しておくと調査が短く済みます。


よくある質問

ACFを使わず標準機能だけでカスタムフィールドは実装できますか

できます。投稿編集画面の「カスタムフィールド」欄(表示されていない場合は編集画面の設定パネルから有効化)で、キーと値を手入力すれば保存されます。テンプレート側の get_post_meta() の書き方はACFと同じです。ただし入力欄がただのテキストボックスなので、運用者が入力ミスしやすい点は考慮してください。クライアントが日常的に触るサイトなら、メタボックスを自作するかACFを入れたほうが問い合わせが減ります。

get_post_meta() の第3引数は truefalse のどちらを使うべきですか

通常のフィールドは true です。値が1つだけ返り、未入力なら空文字列になります。同じメタキーで複数の値を保存している繰り返しフィールドのときだけ false にして、配列で受け取ります。混在させると型が変わって分岐が崩れるため、フィールドごとにどちらかを決めて統一してください。

ブロックテーマでも同じコードが使えますか

そのままでは使えません。ブロックテーマはPHPテンプレートではなくHTMLテンプレートでレイアウトを組むため、カスタムフィールドの出力にはブロックバインディングAPIやカスタムブロックの実装が必要です。既存サイトの改修でクラシックテーマが動いているなら、この記事のコードで問題ありません。

カスタムフィールドの値はSEOに影響しますか

カスタムフィールド自体に直接の評価はありませんが、出力されたHTMLは通常のコンテンツと同じように扱われます。見出しタグに入れる、構造化データに使うといった形で本文に反映されれば、その内容が評価対象になります。逆に、空のラベルだけが並ぶ状態は品質面でマイナスになり得るので、この記事の分岐は入れておいてください。

繰り返しフィールドの件数が多いとページが重くなりますか

投稿メタはWordPressがまとめてキャッシュするため、通常の件数なら問題になりません。重くなるのは、繰り返しの中で画像を大量に出しているケースです。width / height の指定と、ファーストビュー外への loading="lazy" を入れてから計測してください。

カスタムフィールドの値をブロックエディタ側から編集させたいときは

register_post_meta()show_in_rest を有効にしたうえで、サイドバーのプラグインパネルを実装するか、ACFのようにブロックエディタ対応済みのプラグインを使います。標準のメタボックスもブロックエディタの下部に表示されますが、編集画面の体験としては分断されます。クライアントが毎日触る項目なら、この分断は無視できないコストになります。


まとめ

カスタムフィールドの表示で押さえるべき点は、多くありません。

  • get_post_meta( get_the_ID(), 'キー', true ) で取り出し、必ずエスケープしてから出力する
  • 空判定は empty() ではなく !== ''0 を消さないため
  • 見出し・囲みタグ・値をひとまとまりにして、外側の if の内側に入れる
  • 繰り返しは、標準なら第3引数 false の配列、ACFなら have_rows() のループ
  • 単位・記号・3桁区切りは出力側で足し、入力欄には数字だけ入れてもらう
  • 全項目未入力の投稿を1件作り、崩れないか確認してから納品する

コード自体は短く、外部ライブラリも不要です。手間がかかるのは分岐の設計で、そこは「未入力の投稿が必ず出る」という前提を持てるかどうかで決まります。

作業前のバックアップだけは省かないでください。テンプレートの1行が落ちるとサイト全体が白くなるため、戻せる状態を作ってから触るのが結果的に一番速く終わります。