WordPress Logo WordPress お問い合わせフォーム プラグインなしで作成(自作)

更新日:2024年05月21日

作成日:2024年5月14日

WordPress でプラグインを使わずにお問い合わせフォームを作成するための覚書(サンプルコード)です。

お問い合わせフォームのコードの説明はありませんが、コードにコメントは入れてあります。

PHP を使ったお問い合わせフォームの作成方法の詳細は以下のページを御覧ください。

作成するコンタクトフォームの概要

固定ページ(カスタムページテンプレート)を使ってお問い合わせページを作成します。送信は mb_send_mail() を使用します。

入力された内容をデータベースに保存する機能などはなく、シンプルなコンタクトフォームです。

  • お問い合わせページ : contact.php
  • 確認ページ : confirm.php
  • 完了ページ : complete.php

入力値の検証は JavaScript と PHP で行います。また、Google が提供する CAPTCHA 認証システム(reCAPTCHA v3)を実装します。

基本的には通常の PHP を使ったお問い合わせページの作成と同じですが、ファイルの読み込み方法や action 属性に指定する URL、テンプレートなど WordPress 特有の記述があります。

また、WordPress の予約語には注意が必要です。

[注意] name 属性の値に name は使えない

name は WordPress の予約語のため、name 属性に name を指定する($_GET や $_POST で使用する)とエラーになります。name 以外にも error なども予約語になっています。

以下は作成するお問い合わせページの画面です。

入力項目は名前、メールアドレス、件名、メッセージです。

入力後、 確認ボタンをクリックすると「確認ページ」へ遷移します。

確認後、送信ボタンをクリックするとフォームが送信され、「完了ページ」へ遷移します。

以下はこのサンプルのファイル構成で、独自のテーマ(sample)を作成しています。

赤枠で囲んだファイルをお問い合わせフォームで使用します。

この例のテーマは Underscores を利用しています。以下が header.php と footer.php の概要です。

header.php と footer.php を開く
<!doctype html>
<html <?php language_attributes(); ?>>
<head>
  <meta charset="<?php bloginfo( 'charset' ); ?>">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="profile" href="https://gmpg.org/xfn/11">

  <?php wp_head(); ?>
</head>

<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<div id="page" class="site">
  <a class="skip-link screen-reader-text" href="#primary"><?php esc_html_e( 'Skip to content', 'example' ); ?></a>

  <header id="masthead" class="site-header">
    <div class="site-branding">
    ・・・中略・・・
    </div><!-- .site-branding -->

    <nav id="site-navigation" class="main-navigation">
    ・・・中略・・・
    </nav><!-- #site-navigation -->
  </header><!-- #masthead -->
<footer id="colophon" class="site-footer">
    <div class="site-info">
    ・・・中略・・・
    </div><!-- .site-info -->
  </footer><!-- #colophon -->
</div><!-- #page -->

<?php wp_footer(); ?>

</body>
</html>

form-validation.js

検証用 JavaScript を form-validation.js という名前で作成して、js フォルダに配置しています。

//class="validationForm" と novalidate 属性を指定した form 要素を独自に検証
document.addEventListener('DOMContentLoaded', () => {
  //.validationForm を指定した最初の form 要素を取得
  const validationForm = document.querySelector('.validationForm');
  //.validationForm を指定した form 要素が存在すれば
  if(validationForm) {
    //エラーを表示する span 要素に付与するクラス名(エラー用のクラス)
    const errorClassName = 'error-js';

    //required クラスを指定された要素の集まり
    const requiredElems = document.querySelectorAll('.required');
    //email クラスを指定された要素の集まり
    const emailElems =  document.querySelectorAll('.email');
    //maxlength クラスを指定された要素の集まり
    const maxlengthElems =  document.querySelectorAll('.maxlength');

    //エラーメッセージを表示する span 要素を生成して親要素に追加する関数
    //elem :対象の要素
    //errorMessage :表示するエラーメッセージ
    const createError = (elem, errorMessage) => {
      //span 要素を生成
      const errorSpan = document.createElement('span');
      //エラー用のクラスを追加(設定)
      errorSpan.classList.add(errorClassName);
      //aria-live 属性を設定
      errorSpan.setAttribute('aria-live', 'polite');
      //引数に指定されたエラーメッセージを設定
      errorSpan.textContent = errorMessage;
      //elem の親要素の子要素として追加
      elem.parentNode.appendChild(errorSpan);
    }

    //form 要素の submit イベントを使った送信時の処理
    validationForm.addEventListener('submit', (e) => {
      //エラーを表示する要素を全て取得して削除(初期化)
      const errorElems = validationForm.querySelectorAll('.' + errorClassName);
      errorElems.forEach( (elem) => {
        elem.remove();
      });

      //.required を指定した要素を検証
      requiredElems.forEach( (elem) => {
        //値(value プロパティ)の前後の空白文字を削除
        const elemValue = elem.value.trim();
        //値が空の場合はエラーを表示してフォームの送信を中止
        if(elemValue.length === 0) {
          createError(elem, '入力は必須です');
          e.preventDefault();
        }
      });

      //.email を指定した要素を検証
      emailElems.forEach( (elem) => {
        //Email の検証に使用する正規表現パターン
        const pattern = /^([a-z0-9\+_\-]+)(\.[a-z0-9\+_\-]+)*@([a-z0-9\-]+\.)+[a-z]{2,6}$/ui;
        //値が空でなければ
        if(elem.value !=='') {
          //test() メソッドで値を判定し、マッチしなければエラーを表示してフォームの送信を中止
          if(!pattern.test(elem.value)) {
            createError(elem, 'Emailアドレスの形式が正しくないようです。');
            e.preventDefault();
          }
        }
      });

      //.maxlength を指定した要素を検証
      maxlengthElems.forEach( (elem) => {
        //data-maxlength 属性から最大文字数を取得
        const maxlength = elem.dataset.maxlength;
        //または const maxlength = elem.getAttribute('data-maxlength');
        //値が空でなければ
        if(elem.value !=='') {
          //値が maxlength を超えていればエラーを表示してフォームの送信を中止
          if(elem.value.length > maxlength) {
            createError(elem, maxlength + '文字以内でご入力ください');
            e.preventDefault();
          }
        }
      });

      //エラーの最初の要素を取得
      const errorElem =  validationForm.querySelector('.' + errorClassName);
      //エラーがあればエラーの最初の要素の位置へスクロール
      if(errorElem) {
        const errorElemOffsetTop = errorElem.offsetTop;
        window.scrollTo({
          top: errorElemOffsetTop - 40,  //40px 上に位置を調整
          //スムーススクロール
          behavior: 'smooth'
        });
      }
    });
  }
});

functions.php で読み込み

functions.php で is_page() を使って固定ページのスラッグが contact の場合に上記の form-validation.js を読み込みます(関連ページ:CSSやJavaScriptファイルの読み込み)。

//テーマで読み込むスタイルシート・スクリプト
function my_enqueue_script_style() {

  /* その他の JS や CSS の読み込み */

  // contact の場合にのみ form-validation.js を読み込む
  if (is_page('contact')) {
    wp_enqueue_script(
      'contact-form-validation-js',
      get_theme_file_uri('/js/form-validation.js'),
      array(), //依存ファイル
      filemtime(get_theme_file_path('/js/form-validation.js')),
      true
    );
  }
}
add_action('wp_enqueue_scripts', 'my_enqueue_script_style');

libs フォルダのファイル

メール送信に必要な情報などは libs というフォルダを作成してその中に保存し、.htaccess で外部からアクセスできないようにしています。また、全てのテンプレートで使用する検証用の関数などを記述したファイル contact-functions.php もこのフォルダに保存しています。

libs
├── .htaccess //アクセス制御(外部からのアクセスを拒否)
├── contact-functions.php //値を検証する関数やエスケープ処理をする関数のファイル
├── mailvars.php //メールの送信先などの情報を記述したファイル
└── recaptchavars.php  //reCAPTCHA のサイトキーとシークレットキー

以下が libs に配置したファイルとその内容です。

.htaccess

.htaccess には外部からのアクセスを拒否する記述をします。

deny from all

contact-functions.php

contact-functions.php は値を検証する関数やエスケープ処理をする関数を記述したファイルで、テンプレート(contact.php, confirm.php, complete.php)で読み込みます。

<?php
//エスケープ処理を行う関数
function h($var) {
  if(is_array($var)){
    //$varが配列の場合、h()関数をそれぞれの要素について呼び出す(再帰)
    return array_map('h', $var);
  }else{
    if($var === null) return ''; // PHP 8.1.x 対策(null を渡すと Deprecated エラー)
    return htmlspecialchars($var, ENT_QUOTES, 'UTF-8');
  }
}

//入力値に不正なデータがないかなどをチェックする関数
function checkInput($var){
  if(is_array($var)){
    return array_map('checkInput', $var);
  }else{
    //NULLバイト攻撃対策
    if(preg_match('/\0/', $var)){
      die('不正な入力です。');
    }
    //文字エンコードのチェック
    if(!mb_check_encoding($var, 'UTF-8')){
      die('不正な入力です。');
    }
    //改行、タブ以外の制御文字のチェック
    if(preg_match('/\A[\r\n\t[:^cntrl:]]*\z/u', $var) === 0){
      die('不正な入力です。制御文字は使用できません。');
    }
    return $var;
  }
}

mailvars.php

mailvars.php はメールの送信処理の際に使用する送信情報などを定数として定義したファイルで、テンプレート(complete.php)で読み込みます。値は実際に使用するメールアドレスや名前に置き換えます。

<?php
//メールの宛先(To)のメールアドレス
define('MAIL_TO', "info@example.com");
//メールの宛先(To)の名前
define('MAIL_TO_NAME', "Example Co.,Ltd ");
//Cc のメールアドレス
define('MAIL_CC', 'foo@example.com');
//Cc の名前
define('MAIL_CC_NAME', 'Foo');
//Bcc
define('MAIL_BCC', 'bar@example.com');
//Return-Pathに指定するメールアドレス
define('MAIL_RETURN_PATH', 'info@example.com');
//自動返信の返信先名前
define('AUTO_REPLY_NAME', 'Example Co.,Ltd');

Cc や Bcc はオプションですが、MAIL_CC や MAIL_CC_NAME、MAIL_BCC を定義しない場合は、完了ページ(complete.php)の Cc や Bcc の部分も削除する必要があります(定義されていない定数を参照すると重大なエラーになり、ページが表示されなくなります)。

recaptchavars.php

recaptchavars.php は reCAPTCHA のサイトキーとシークレットキーを記述したファイルで、テンプレート(confirm.php, complete.php)で読み込みます。

サイトキーとシークレットキーは reCAPTCHA にサイトを登録する際に発行されます(サイトの登録には Google のユーザーアカウントが必要です)。

<?php
// reCAPTCHA v3 サイトキー
define('V3_SITEKEY', '6Le6Txxxxxxxxx...xxxxxxxxxx');

// reCAPTCHA v3 シークレットキー
define('V3_SECRETKEY', '6Le6Txxxxxxx...xxxxxxxxxx');

MAMP などのローカル環境で利用するにはドメインに localhost を登録します。

関連ページ:Google reCAPTCHA の使い方(v2/v3)

お問い合わせページ(contact.php)

contact.php というお問い合わせページのテンプレートファイルを作成します。

PHP

libs/contact-functions.php の読み込みでは get_theme_file_path() を使っています。

この例では、テンプレートファイルを contact.php とするので、Template Name: を使ってカスタムページテンプレートとしています(37行目)。

カスタムページテンプレートを使わず、テンプレート階層に対応したテンプレート名とすることもできます。例えば、page-contact.php とすれば、固定ページの作成でテンプレートを選択する必要はありませんし、Template Name: の記述も不要です。

HTML

HTML の部分は header.php や footer.php の切り出し方により、構造を環境に合わせて変更する必要があるかと思います。また、使用するタグ( main や section 等)やクラス名なども必要に応じて変更します。

form 要素には validationForm クラスと novalidate 属性を指定して form-validation.js で検証する対象にしています(form-validation.js は validationForm クラスの要素を検証します)。

action 属性には home_url() を使って esc_url(home_url('/confirm/')) で確認ページを送信先として指定しています(確認ページのスラッグを confirm としています)。

また、name は WordPress の予約語なので「お名前」の name 属性の値を name ではなく、usname としています(name="name" として送信すると 404 エラーなどになり、うまく動作しません)。

各 input 要素は JavaScript の検証でエラーを追加しやすいように div 要素で囲み、検証対象とするためのクラス(required や maxlength)や属性(data-maxlength)を設定しています。

<?php
// 既にセッションが開始されていなければセッションを開始
if (session_status() !== PHP_SESSION_ACTIVE) {
  session_start();
}
// セッションIDを更新して変更(セッションハイジャック対策)
session_regenerate_id();
// エスケープ処理やデータチェックを行う関数のファイルの読み込み
require get_theme_file_path('/libs/contact-functions.php');
// セッション変数を初期化
$uname = $_SESSION['uname'] ?? NULL;  // name を使うとエラーになるので注意
$email = $_SESSION['email'] ?? NULL;
$subject = $_SESSION['subject'] ?? NULL;
$body = $_SESSION['body'] ?? NULL;
$errors = $_SESSION['errors'] ?? NULL;  // error を使うとエラーになるので注意
// 個々のエラーを NULL で初期化
$errors_uname = $errors['uname'] ?? NULL;
$errors_email = $errors['email'] ?? NULL;
$errors_subject = $errors['subject'] ?? NULL;
$errors_body = $errors['body'] ?? NULL;
// CSRF対策の固定トークンを生成
if (!isset($_SESSION['ticket'])) {
  // セッション変数にトークンを代入
  $_SESSION['ticket'] = bin2hex(random_bytes(32));
}
// トークンを変数に代入
$ticket = $_SESSION['ticket'];
?>

<?php
/*
Template Name: お問い合わせページ
*/
get_header(); ?>

<main class="main-content">
  <section id="page-<?php the_ID(); ?>" <?php post_class(); ?>>
    <?php the_title('<h2 class="entry-title">', '</h2>'); ?>
    <div class="entry-content">
      <p>お問い合わせは以下のコンタクトフォームをお使いください。</p>
      <p>または <a href="<?php echo esc_url('mailto:' . antispambot('foo@example.com')); ?>"> <?php echo esc_html(antispambot('foo@example.com')); ?></a> までメールにてお問い合わせください。</p>
      <?php
      // 固定ページに記述したコンテンツを表示する場合(配置する場所は必要に応じて変更)
      while (have_posts()) :
        the_post();
        the_content();
      endwhile;
      ?>
      <form class="form validationForm" action="<?php echo esc_url(home_url('/confirm/')); ?>" method="post" novalidate>
        <label for="uname">お名前 <span class="error-php"><?php echo h($errors_uname); ?></span></label>
        <div class="field">
          <input class="required maxlength" data-maxlength="30" id="uname" name="uname" value="<?php echo h($uname); ?>" placeholder="お名前(必須)">
        </div>
        <label for="email">メールアドレス <span class="error-php"><?php echo h($errors_email); ?></span></label>
        <div class="field">
          <input class="required email" type="email" id="email" name="email" value="<?php echo h($email); ?>" placeholder="Email(必須)">
        </div>
        <label for="subject">件名 <span class="error-php"><?php echo h($errors_subject); ?></span></label>
        <div class="field">
          <input class="required maxlength" data-maxlength="100" id="subject" name="subject" value="<?php echo h($subject); ?>" placeholder="件名(必須)">
        </div>
        <label for="body">メッセージ <span class="error-php"><?php echo h($errors_body); ?></span></label>
        <div class="field">
          <textarea class="required maxlength" data-maxlength="1000" rows="10" id="body" name="body" placeholder="メッセージ(必須 1000 文字まで)" aria-label="Textarea"><?php echo h($body); ?></textarea>
        </div>
        <!-- 確認ページへ渡すトークンの隠しフィールド -->
        <input type="hidden" name="ticket" value="<?php echo h($ticket); ?>">
        <div class="field">
          <button id="confirm" type="submit">確認</button>
        </div>
      </form>
    </div><!-- .entry-content -->
  </section>
</main><!-- #main -->
<?php
get_footer();

antispambot()

このサンプルではテキストを直接テンプレートに記述しています。そして「または foo@example.com までメールにてお問い合わせください。」のようなメールのリンクを表示しています(41行目)。

メールアドレスのリンクをそのまま記述してしまうとスパムボットに収集される可能性が高くなります。

そのため、メールリンクの出力では以下のように、指定した文字列をエンティティ化してくれる WordPress の関数 antispambot() を使っています。

<a href="<?php echo esc_url('mailto:' . antispambot('foo@example.com')); ?>"> <?php echo esc_html(antispambot('foo@example.com')); ?></a> 

上記は以下のようにエンティティ化してソースコードに出力されます(表示上は foo@example.com)。

<a href="mailto:&#102;oo&#064;&#101;&#120;a&#109;p&#108;&#101;&#046;&#099;&#111;m"> &#102;o&#111;&#064;&#101;x&#097;&#109;pl&#101;&#046;&#099;&#111;&#109;</a> 
投稿でメールアドレスをエンティティ化

上記のテンプレートの 44〜47行目にはループを記述してあるので、テキストを直接テンプレートに記述するのではなく、固定ページのコンテンツを出力することもできます。

その際、メールのリンクを出力する場合、投稿には PHP を記述できないので、例えば、以下のように functions.php にメールアドレスをエンティティ化するショートコードを作成します(antispambot ページ から転用)。

is_email() は文字列がメールアドレス形式かどうかを調べる関数です。

function email_antispambot_shortcode( $atts , $content = null ) {
  if ( ! is_email( $content ) ) {
    return;
  }
  return '<a href="' . esc_url('mailto:' . antispambot( $content ) ) . '">' . esc_html( antispambot( $content ) ) . '</a>';
}
add_shortcode( 'email', 'email_antispambot_shortcode' );

投稿のコンテンツのメールアドレスを表示させたいところに下記のショートコードを記述します。

[email]foo@example.com[/email]

固定ページの作成

お問い合わせページの固定ページを作成します。

「テンプレート」で上記で作成したページテンプレート(お問い合わせページ)を選択します。

URL でパーマリンクにページのスラッグ contact を指定します。

必要に応じてコンテンツを記述します。

以下のように記述した場合は、テンプレート(contact.php)の40〜41行目のテキストは削除します。以下ではメールのリンクにショートコードを使用しています。

確認ページ(confirm.php)

confirm.php という確認ページのテンプレートを作成します。

PHP

お問い合わせページ(contact.php)同様、ファイルの読み込みでは get_theme_file_path() を使い、Template Name: を使ってカスタムページテンプレートとしています。

HTML

送信する場合の action 属性には home_url() を使って esc_url(home_url('/complete/')) で完了ページを送信先として指定し、戻る場合の action 属性には esc_url(home_url('/contact/')) でお問い合わせページを送信先として指定しています。

<?php
// セッションを開始
if (session_status() !== PHP_SESSION_ACTIVE) {
  session_start();
}

// エスケープ処理やデータチェックを行う関数のファイルの読み込み
require get_theme_file_path('/libs/contact-functions.php');
// reCAPTCHA サイトキーを記述したファイルの読み込み
require get_theme_file_path('/libs/recaptchavars.php');
$siteKey = V3_SITEKEY;

// POSTされたデータをチェック
$_POST = checkInput($_POST);

// 固定トークンを確認(CSRF対策)
if (isset($_POST['ticket'], $_SESSION['ticket'])) {
  $ticket = $_POST['ticket'];
  if ($ticket !== $_SESSION['ticket']) {
    // トークンが一致しない場合は処理を中止
    die('Access Denied!');
  }
} else {
  // トークンが存在しない場合は処理を中止(直接このページにアクセスするとエラーになる)
  die('Access Denied(No direct access allowed!)');
}
// 値が null であれば、空文字列に変換する関数
function nullToString($val) {
  if ($val === null) return '';
  return $val;
}
// POSTされたデータの前後にあるホワイトスペースを削除してを変数に格納
$uname = trim(nullToString(filter_input(INPUT_POST, 'uname')));
$email = trim(nullToString(filter_input(INPUT_POST, 'email')));
$subject = trim(nullToString(filter_input(INPUT_POST, 'subject')));
$body = trim(nullToString(filter_input(INPUT_POST, 'body')));
// エラーメッセージを保存する配列の初期化
$errors = array();
// 値の検証(入力内容が条件を満たさない場合はエラーメッセージを配列 $errors に設定)
if ($uname == '') {
  $errors['uname'] = '必須';
  // 制御文字でないことと文字数をチェック
} else if (preg_match('/\A[[:^cntrl:]]{1,30}\z/u', $uname) == 0) {
  $errors['uname'] = '30文字以内';
}
if ($email == '') {
  $errors['email'] = '必須';
} else { // メールアドレスを正規表現でチェック
  $pattern = '/^([a-z0-9\+_\-]+)(\.[a-z0-9\+_\-]+)*@([a-z0-9\-]+\.)+[a-z]{2,6}$/uiD';
  if (!preg_match($pattern, $email)) {
    $errors['email'] = '形式が正しくありません';
  }
}

if ($subject == '') {
  $errors['subject'] = '必須';
  // 制御文字でないことと文字数をチェック
} else if (preg_match('/\A[[:^cntrl:]]{1,100}\z/u', $subject) == 0) {
  $errors['subject'] = '100文字以内';
}
if ($body == '') {
  $errors['body'] = '必須';
  // 制御文字(タブ、復帰、改行を除く)でないことと文字数をチェック
} else if (preg_match('/\A[\r\n\t[:^cntrl:]]{1,1050}\z/u', $body) == 0) {
  $errors['body'] = '1000文字以内';
}
// POSTされたデータとエラーの配列をセッション変数に保存
$_SESSION['uname'] = $uname;
$_SESSION['email'] = $email;
$_SESSION['subject'] = $subject;
$_SESSION['body'] = $body;
$_SESSION['errors'] = $errors;
// チェックの結果にエラーがある場合は入力フォームに戻す
if (count($errors) > 0) {
  // エラーがある場合
  $dirname = dirname($_SERVER['SCRIPT_NAME']);
  $dirname = $dirname == DIRECTORY_SEPARATOR ? '' : $dirname;
  // サーバー変数 $_SERVER['HTTPS'] が取得出来ない環境用(オプション)
  if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) and $_SERVER['HTTP_X_FORWARDED_PROTO'] === "https") {
    $_SERVER['HTTPS'] = 'on';
  }
  // 入力画面(contact.php)の URL
  $url = (empty($_SERVER['HTTPS']) ? 'http://' : 'https://') . $_SERVER['SERVER_NAME'] . $dirname . '/contact/';
  header('HTTP/1.1 303 See Other');
  header('location: ' . $url);
  exit;
}
?>
<?php
/*
Template Name: お問い合わせ 確認ページ
*/
get_header(); ?>
<main class="main-content">
  <section id="page-<?php the_ID(); ?>" <?php post_class(); ?>>
    <?php the_title('<h2 class="entry-title">', '</h2>'); ?>
    <div class="entry-content">
      <p>以下の内容でよろしければ「送信」をクリックしてください。</p>
      <p>内容を変更する場合は「戻る」をクリックして入力画面にお戻りください。</p>
      <?php
      // 固定ページに記述したコンテンツを表示する場合(配置する場所は必要に応じて変更)
      while (have_posts()) :
        the_post();
        the_content();
      endwhile;
      ?>
        <table class="confirm-table">
          <tr>
            <th>お名前</th>
            <td><?php echo h($uname); ?></td>
          </tr>
          <tr>
            <th>メールアドレス</th>
            <td><?php echo h($email); ?></td>
          </tr>
          <tr>
            <th>件名</th>
            <td><?php echo h($subject); ?></td>
          </tr>
          <tr>
            <th>メッセージ</th>
            <td><?php echo nl2br(h($body)); ?></td>
          </tr>
        </table>
      <div class="flex">
        <form action="<?php echo esc_url(home_url('/contact/')); ?>" method="post">
          <button type="submit">戻る</button>
        </form>
        <form id="complete" action="<?php echo esc_url(home_url('/complete/')); ?>" method="post">
          <!-- 完了ページへ渡すトークンの隠しフィールド -->
          <input type="hidden" name="ticket" value="<?php echo h($ticket); ?>">
          <button type="submit">送信</button>
        </form>
      </div>
    </div>
  </section>
</main>
<!-- Google Recaptcha -->
<script src="https://www.google.com/recaptcha/api.js?render=<?php echo $siteKey; ?>"></script>
<script>
  // id 属性に complete を指定した form 要素を取得
  const myForm = document.getElementById('complete');
  // 上記で取得したフォーム要素に submit イベントハンドラを設定
  myForm.addEventListener('submit', (e) => {
    // デフォルトの動作(送信)を停止
    e.preventDefault();
    const action_name = 'contact'; //アクション名
    // トークンを取得
    grecaptcha.ready(function() {
      grecaptcha.execute('<?php echo $siteKey; ?>', {action: action_name}).then(function(token) {
        const token_input = document.createElement('input'); // input 要素を生成
        token_input.type = 'hidden';
        token_input.name = 'g-recaptcha-response';
        token_input.value = token; // トークンを値に設定
        myForm.appendChild(token_input);  // フォームに input 要素を追加
        const action_input = document.createElement('input'); // input 要素を生成
        action_input.type = 'hidden';
        action_input.name = 'action';
        action_input.value = action_name;  // アクション名を値に設定
        myForm.appendChild(action_input);  // フォームに input 要素を追加
        myForm.submit();  // フォームを送信
      });
    });
  });
</script>
<?php
get_footer();

固定ページの作成

確認ページの固定ページを作成します。

「テンプレート」で上記テンプレートの Template Name: に指定した「お問い合わせ 確認ページ」を選択します。

URL でパーマリンクにページのスラッグ confirm を指定します。

必要に応じてコンテンツを記述します。

完了ページ(complete.php)

complete.php という完了ページのテンプレートを作成します。

PHP

お問い合わせページや確認ページ同様、ファイルの読み込みでは get_theme_file_path() を使い、Template Name: を使ってカスタムページテンプレートとしています。

[重要] 送信で Cc や Bcc 使わない(mailvars.php で MAIL_CC や MAIL_BCC などの定数を定義していない)場合は 119行目や121行目をコメントアウトするか削除する必要があります。

mailvars.php で定義していない定数(MAIL_CC や MAIL_BCC など)を参照すると、重大なエラー(Fatal error)となります。定数が定義されているかどうかを defined() で判定することもできます。

HTML

161〜170行目は reCAPTCHA の結果表示用(テスト用)の記述です。コメントアウトを外すと reCAPTCHA の判定結果を表示します。

<?php
// セッションを開始
if (session_status() !== PHP_SESSION_ACTIVE) {
  session_start();
}

// エスケープ処理やデータチェックを行う関数のファイルの読み込み
require get_theme_file_path('/libs/contact-functions.php');
// メールアドレス等を記述したファイルの読み込み
require get_theme_file_path('/libs/mailvars.php');
// reCAPTCHA サイトキーを記述したファイルの読み込み
require get_theme_file_path('/libs/recaptchavars.php');
// reCAPTCHA サイトキー
$siteKey = V3_SITEKEY;
// reCAPTCHA シークレットキー
$secretKey = V3_SECRETKEY;

// POSTされたデータをチェック
$_POST = checkInput($_POST);
// 固定トークンを確認(CSRF対策)
if (isset($_POST['ticket'], $_SESSION['ticket'])) {
  $ticket = $_POST['ticket'];
  if ($ticket !== $_SESSION['ticket']) {
    // トークンが一致しない場合は処理を中止
    die('Access denied');
  }
} else {
  // トークンが存在しない場合(入力ページにリダイレクト)
  // die( 'Access Denied(直接このページにはアクセスできません)' );  //処理を中止する場合
  $dirname = dirname($_SERVER['SCRIPT_NAME']);
  $dirname = $dirname == DIRECTORY_SEPARATOR ? '' : $dirname;
  // サーバー変数 $_SERVER['HTTPS'] が取得出来ない環境用(オプション)
  if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) and $_SERVER['HTTP_X_FORWARDED_PROTO'] === "https") {
    $_SERVER['HTTPS'] = 'on';
  }
  $url = (empty($_SERVER['HTTPS']) ? 'http://' : 'https://') . $_SERVER['SERVER_NAME'] . $dirname . '/contact/';
  header('HTTP/1.1 303 See Other');
  header('location: ' . $url);
  exit; // 忘れないように
}

// reCAPTCHA トークン
$token = filter_input(INPUT_POST, 'g-recaptcha-response');
// reCAPTCHA アクション名
$action = filter_input(INPUT_POST, 'action');
// reCAPTCHA の検証を通過したかどうかの真偽値
$rcv3_result = false;

// reCAPTCHA のトークンとアクション名が取得できていれば
if ($token && $action) {
  // cURL セッションを初期化(API のレスポンスの取得)
  $ch = curl_init();
  // curl_setopt() により転送時のオプションを設定
  // URL の指定
  curl_setopt($ch, CURLOPT_URL, "https://www.google.com/recaptcha/api/siteverify");
  // HTTP POST メソッドを使う
  curl_setopt($ch, CURLOPT_POST, true);
  // API パラメータの指定
  curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query(array(
    'secret' => $secretKey,
    'response' => $token
  )));
  //c url_execの返り値を文字列にする
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  // 転送を実行してレスポンスを $api_response に格納
  $api_response = curl_exec($ch);
  // セッションを終了
  curl_close($ch);

  // レスポンスの $json(JSON形式)をデコード
  $rc_result = json_decode($api_response);

  // レスポンスの値を判定
  if ($rc_result->success && $rc_result->action === $action && $rc_result->score >= 0.5) {
    // success が true でアクション名が一致し、スコアが 0.5 以上の場合は合格
    $rcv3_result = true;
  } else {
    // 上記以外の場合は 不合格
    $rcv3_result = false;
  }
}

// メールの送信結果の初期値を false に
$result = false;

// reCAPTCHA の検証結果が合格の場合はメール送信処理を実行
if ($rcv3_result) {

  // お問い合わせ日時を日本時間に
  date_default_timezone_set('Asia/Tokyo');

  // 変数にエスケープ処理したセッション変数の値を代入
  $uname = h($_SESSION['uname']);
  $email = h($_SESSION['email']);
  $subject = h($_SESSION['subject']);
  $body = h($_SESSION['body']);

  // メール本文の組み立て
  $mail_body = 'コンタクトページからのお問い合わせ' . "\n\n";
  $mail_body .=  date("Y-m-d H:i:s") . "\n\n";
  $mail_body .=  "お名前: " . $uname . "\n";
  $mail_body .=  "Email: " . $email . "\n";
  $mail_body .=  "メッセージ: " . "\n" . $body;

  //-------- sendmail(mb_send_mail)を使ったメールの送信処理------------

  // メールの宛先(名前<メールアドレス> の形式)。値は mailvars.php に記載
  $mailTo = mb_encode_mimeheader(MAIL_TO_NAME) . "<" . MAIL_TO . ">";

  // Return-Pathに指定するメールアドレス
  $returnMail = MAIL_RETURN_PATH; //
  // mbstringの日本語設定
  mb_language('ja');
  mb_internal_encoding('UTF-8');

  // 送信者情報(From ヘッダー)の設定()
  $header = "From: " . mb_encode_mimeheader($uname) . "<" . $email . ">\n";
  // mailvars.php で Cc の定数(MAIL_CC、MAIL_CC_NAME)を定義していない場合は以下を削除かコメントアウト
  $header .= "Cc: " . mb_encode_mimeheader(MAIL_CC_NAME) . "<" . MAIL_CC . ">\n";
  // mailvars.php で Bcc の定数(MAIL_BCC)を定義していない場合は以下を削除かコメントアウト
  $header .= "Bcc: <" . MAIL_BCC . ">";

  // メールの送信(結果を変数 $result に格納)
  if (ini_get('safe_mode')) {
    // セーフモードがOnの場合は第5引数が使えない
    $result = mb_send_mail($mailTo, $subject, $mail_body, $header);
  } else {
    $result = mb_send_mail($mailTo, $subject, $mail_body, $header, '-f' . $returnMail);
  }
}

// メール送信の結果判定
if ($result) {
  // 成功した場合はセッションを破棄
  $_SESSION = array(); // 空の配列を代入し、すべてのセッション変数を消去
  session_destroy(); // セッションを破棄
} else {
  // 送信失敗時(もしあれば)
}
?>
<?php
/*
Template Name: お問い合わせ 完了ページ
*/
get_header(); ?>
<main class="main-content">
  <section id="page-<?php the_ID(); ?>" <?php post_class(); ?>>
    <?php the_title('<h2 class="entry-title">', '</h2>'); ?>
    <div class="entry-content">
      <?php if ($result) : ?>
        <h3>送信完了!</h3>
        <p>お問い合わせいただきありがとうございます。</p>
        <p>送信完了いたしました。</p>
      <?php else : ?>
        <h3>送信失敗</h3>
        <p>申し訳ございませんが、送信に失敗しました。</p>
        <p>しばらくしてもう一度お試しになるか、 <a href="<?php echo esc_url('mailto:' . antispambot('foo@exmaple.com')); ?>"> <?php echo esc_html(antispambot('foo@exmaple.com')); ?></a> までメールにてご連絡ください。 </p>
      <?php endif; ?>

      <?php
      // 固定ページに記述したコンテンツを表示する場合(配置する場所は必要に応じて変更)
      while (have_posts()) :
        the_post();
        the_content();
      endwhile;
      ?>

      <!-- ここから reCAPTCHA 結果表示(テスト用)-->
      <!-- <?php if (isset($rc_result)) : ?>
        <h4>reCAPTCHA 判定結果表示(テスト用)</h4>
        <ul>
          <li><?php echo 'success 判定 :' . $rc_result->success; ?></li>
          <li><?php echo 'アクション名 : ' . $rc_result->action ?></li>
          <li><?php echo 'スコア : ' . $rc_result->score; ?></li>
        </ul>
        <h4>reCAPTCHA API レスポンス</h4>
        <pre><?php var_dump($rc_result); ?></pre>
      <?php endif; ?> -->
      <!-- ここまで reCAPTCHA 結果表示(テスト用)-->

    </div>
  </section>
</main>
<?php
get_footer();

固定ページの作成

完了ページの固定ページを作成します。

「テンプレート」で上記テンプレートの Template Name: に指定した「お問い合わせ 完了ページ」を選択します。

URL でパーマリンクにページのスラッグ complete を指定します。

必要に応じてコンテンツを記述します。固定ページから画像なども挿入できます。

reCAPTCHA 判定結果表示

complete.php の reCAPTCHA 判定結果表示のコメントアウトを外すと、例えば以下のような判定結果を確認することができます。

これで3つのテンプレートと3つの固定ページを作成しました。

style(CSS)

以下はお問い合わせページのスタイルの例です。

:has() と CSS コンテナクエリ(@container)を使って、フォーム要素の親要素の幅が 640px 以上の場合はラベルと input 要素を一行に配置しています。また、CSS ネスティングも使っています。

/* Contact Page
-----------------------------------------------------------------*/

*:has(> .form) {
  container-type: inline-size;
}

.form {
  display: grid;
  grid-template-columns: 1fr;
  gap: 10px;
  margin: 50px 20px 50px 0;
  label {
    font-size: 0.875rem;
  }
}

@container (width >= 640px) {
  .form {
    grid-template-columns: 200px 1fr;
    grid-gap: 16px;
    align-items: center;
  }
  label {
    grid-column: 1 / 2;
  }
  .field {
    grid-column: 2 / 3;
  }
}

input,
textarea {
  font-size: 0.875rem;
  padding: 8px 12px;
  border: 1px solid lightgray;
  width: 100%;
}

input::placeholder,
textarea::placeholder {
  font-size: 0.875rem;
  color: #ccc;
}

[type="submit"] {
  border: 1px solid lightgray;
  background-color: #fff;
  color: #666;
  padding: 8px 16px;
  width: 120px;
  cursor: pointer;
  transition: 0.3s;
}

[type="submit"]:hover {
  background-color: #eee;
  border-color: #999;
}

.flex {
  display: flex;
  column-gap: 20px;
}

.confirm-table {
  border-collapse: collapse;
  border-spacing: 0;
  width: 100%;
  max-width: 640px;
  margin-bottom: 20px;
  font-size: 0.875rem;
  margin-block: 50px;

  th {
    padding: 16px 12px;
    text-align: left;
    vertical-align: middle;
    font-weight: 400;
    color: #999;
    min-width: 100px;
    border-bottom: 1px solid #4dc179;
  }

  td {
    padding: 16px 12px;
    vertical-align: top;
    word-break: break-all;
    border-bottom: 1px solid #c1c7c6;
  }
}

/* エラーメッセージ */
.error-js,
.error-php {
  color: red;
  font-size: 13px;
}

検証用 JavaScript サンプル

以下は前述の検証用 JavaScript より機能の多い検証用の JavaScript のサンプルです。

この JavaScript は初回の検証後、問題がある場合は input イベントなどを使って入力時に値を判定します。また、チェックボックスやラジオボタン、セレクトボックスなどの検証やテキストエリアの入力文字数を表示する機能もあるため、記述が少し長くなっています。

関連項目:

//validationForm クラス と novalidate 属性を指定した form 要素を独自に検証
document.addEventListener('DOMContentLoaded', () => {
  const validationForm = document.getElementsByClassName('validationForm')[0];
  let validateAfterFirstSubmit = true;
  const errorClassName = 'error-js';

  if(validationForm) {
    const requiredElems = document.querySelectorAll('.required');
    const patternElems =  document.querySelectorAll('.pattern');
    const equalToElems = document.querySelectorAll('.equal-to');
    const minlengthElems =  document.querySelectorAll('.minlength');
    const maxlengthElems =  document.querySelectorAll('.maxlength');
    const showCountElems =  document.querySelectorAll('.showCount');

    const addError = (elem, className, defaultMessage) => {
      let errorMessage = defaultMessage;
      if(elem.hasAttribute('data-error-' + className)) {
        const dataError = elem.getAttribute('data-error-' + className);
        if(dataError) {
          errorMessage = dataError;
        }
      }
      if(!validateAfterFirstSubmit) {
        const errorSpan = document.createElement('span');
        errorSpan.classList.add(errorClassName, className);
        errorSpan.setAttribute('aria-live', 'polite');
        errorSpan.textContent = errorMessage;
        elem.parentNode.appendChild(errorSpan);
      }
    }

    const isValueMissing = (elem) => {
      if(elem.tagName === 'INPUT' && elem.getAttribute('type') === 'radio') {
        const className = 'required-radio';
        const errorSpan = elem.parentElement.querySelector('.' + errorClassName + '.' + className);
        const checkedRadio = elem.parentElement.querySelector('input[type="radio"]:checked');
        if(checkedRadio === null) {
         if(!errorSpan) {
            addError(elem, className, '選択は必須です');
          }
          return true;
        } else{
          if(errorSpan) {
            elem.parentNode.removeChild(errorSpan);
          }
          return false;
        }
      }else if(elem.tagName === 'INPUT' && elem.getAttribute('type') === 'checkbox') {
        const className = 'required-checkbox';
        const errorSpan = elem.parentElement.querySelector('.' + errorClassName + '.' + className);
        const checkedCheckbox = elem.parentElement.querySelector('input[type="checkbox"]:checked');
        if(checkedCheckbox === null) {
          if(!errorSpan) {
            addError(elem, className, '選択は必須です');
          }
          return true;
        }else{
          if(errorSpan) {
            elem.parentNode.removeChild(errorSpan);
          }
          return false;
        }
      }else{
        const className = 'required';
        const errorSpan = elem.parentElement.querySelector('.' + errorClassName + '.' + className);
        if(elem.value.trim().length === 0) {
          if(!errorSpan) {
            if(elem.tagName === 'SELECT') {
              addError(elem, className, '選択は必須です');
            }else{
              addError(elem, className, '入力は必須です');
            }
          }
          return true;
        }else{
          if(errorSpan) {
            elem.parentNode.removeChild(errorSpan);
          }
          return false;
        }
      }
    }

    requiredElems.forEach( (elem) => {
      if(elem.tagName === 'INPUT' && (elem.getAttribute('type') === 'radio' || elem.getAttribute('type') === 'checkbox' )){
        const elems = elem.parentElement.querySelectorAll(elem.tagName);
        elems.forEach( (elemsChild) => {
          elemsChild.addEventListener('change', () => {
            isValueMissing(elemsChild);
          });
        });
      }else{
        elem.addEventListener('input', () => {
          isValueMissing(elem);
        });
      }
    });

    const isPatternMismatch = (elem) => {
      const className = 'pattern';
      const attributeName = 'data-' + className;
      let pattern = new RegExp('^' + elem.getAttribute(attributeName) + '$');
      if(elem.getAttribute(attributeName) ==='email') {
        pattern = /^([a-z0-9\+_\-]+)(\.[a-z0-9\+_\-]+)*@([a-z0-9\-]+\.)+[a-z]{2,6}$/ui;
      }else if(elem.getAttribute(attributeName) ==='tel') {
        pattern = /^\(?\d{2,5}\)?[-(\.\s]{0,2}\d{1,4}[-)\.\s]{0,2}\d{3,4}$/;
      }
      const errorSpan = elem.parentElement.querySelector('.' + errorClassName + '.' + className);
      if(elem.value.trim() !=='') {
        if(!pattern.test(elem.value)) {
          if(!errorSpan) {
            addError(elem, className, '入力された値が正しくないようです');
          }
          return true;
        }else{
          if(errorSpan) {
            elem.parentNode.removeChild(errorSpan);
          }
          return false;
        }
      }else if(elem.value ==='' && errorSpan) {
        elem.parentNode.removeChild(errorSpan);
      }
    }

    patternElems.forEach( (elem) => {
      elem.addEventListener('input', () => {
        isPatternMismatch(elem);
      });
    });

    const isNotEqualTo = (elem) => {
      const className = 'equal-to';
      const attributeName = 'data-' + className;
      const equalTo = elem.getAttribute(attributeName);
      const equalToElem = document.getElementById(equalTo);
      const errorSpan = elem.parentElement.querySelector('.' + errorClassName + '.' + className);
      if(elem.value.trim() !=='' && equalToElem.value.trim() !=='') {
        if(equalToElem.value !== elem.value) {
          if(!errorSpan) {
            addError(elem, className, '入力された値が一致しません');
          }
          return true;
        }else{
          if(errorSpan) {
            elem.parentNode.removeChild(errorSpan);
          }
          return false;
        }
      }
    }

    equalToElems.forEach( (elem) => {
      elem.addEventListener('input', () => {
        isNotEqualTo(elem);
      });
      const compareTarget = document.getElementById(elem.getAttribute('data-equal-to'));
      if(compareTarget) {
        compareTarget.addEventListener('input', () => {
          isNotEqualTo(elem);
        });
      }
    });

    const getValueLength = (value) => {
      return (value.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]|[\s\S]/g) || []).length;
    }

    const isTooShort = (elem) => {
      const className = 'minlength';
      const attributeName = 'data-' + className;
      const minlength = elem.getAttribute(attributeName);
      const errorSpan = elem.parentElement.querySelector('.' + errorClassName + '.' + className);
      if(elem.value !=='') {
        const valueLength = getValueLength(elem.value);
        if(valueLength < minlength) {
          if(!errorSpan) {
            addError(elem, className, minlength + '文字以上で入力ください');
          }
          return true;
        }else{
          if(errorSpan) {
            elem.parentNode.removeChild(errorSpan);
          }
          return false;
        }
      }else if(elem.value ==='' && errorSpan) {
        elem.parentNode.removeChild(errorSpan);
      }
    }

    minlengthElems.forEach( (elem) => {
      elem.addEventListener('input', () => {
        isTooShort(elem);
      });
    });

    const isTooLong = (elem) => {
      const className = 'maxlength';
      const attributeName = 'data-' + className;
      const maxlength = elem.getAttribute(attributeName);
      const errorSpan = elem.parentElement.querySelector('.' + errorClassName + '.' + className);
      if(elem.value !=='') {
        const valueLength = getValueLength(elem.value);
        if(valueLength > maxlength) {
          if(!errorSpan) {
            addError(elem, className, maxlength + '文字以内で入力ください');
          }
          return true;
        }else{
          if(errorSpan) {
            elem.parentNode.removeChild(errorSpan);
          }
          return false;
        }
      }else if(elem.value ==='' && errorSpan) {
        elem.parentNode.removeChild(errorSpan);
      }
    }

    maxlengthElems.forEach( (elem) => {
      elem.addEventListener('input', () => {
        isTooLong(elem);
      });
    });

    showCountElems.forEach( (elem) => {
      const dataMaxlength = elem.getAttribute('data-maxlength');
      if(dataMaxlength && !isNaN(dataMaxlength)) {
        const countElem = document.createElement('p');
        countElem.classList.add('countSpanWrapper');
        countElem.innerHTML = '<span class="countSpan">0</span>/' + parseInt(dataMaxlength);
        elem.parentNode.appendChild(countElem);
      }
      elem.addEventListener('input', (e) => {
        const countSpan = elem.parentElement.querySelector('.countSpan');
        if(countSpan) {
          const count = getValueLength(e.currentTarget.value);
          countSpan.textContent = count;
          if(count > dataMaxlength) {
            countSpan.style.setProperty('color', 'red');
            countSpan.classList.add('overMaxCount');
          }else{
            countSpan.style.removeProperty('color');
            countSpan.classList.remove('overMaxCount');
          }
        }
      });
    });

    validationForm.addEventListener('submit', (e) => {
      validateAfterFirstSubmit = false;
      requiredElems.forEach( (elem) => {
        if(isValueMissing(elem)) {
          e.preventDefault();
        }
      });
      patternElems.forEach( (elem) => {
        if(isPatternMismatch(elem)) {
          e.preventDefault();
        }
      });
      minlengthElems.forEach( (elem) => {
        if(isTooShort(elem)) {
          e.preventDefault();
        }
      });
      maxlengthElems.forEach( (elem) => {
        if(isTooLong(elem)) {
          e.preventDefault();
        }
      });
      equalToElems.forEach( (elem) => {
        if(isNotEqualTo(elem)) {
          e.preventDefault();
        }
      });

      const errorElem = document.querySelector('.' + errorClassName);
      if(errorElem) {
        const errorElemOffsetTop = errorElem.offsetTop;
        window.scrollTo({
          top: errorElemOffsetTop - 40,
          behavior: 'smooth'
        });
      }
    });
  }
});

上記を先の検証用 JS と同じ名前で保存すれば、functions.php の読み込みは変更必要ありません(異なる名前で保存した場合は、読み込み先を変更します)。

また、上記の JavaScript を使用するには、contact.php の form 部分を以下に変更する必要があります。input 要素に属性やクラスなどを追加しています。

data-error-xxxx 属性を指定して、デフォルトエラーメッセージを上書きできます。

<form class="form validationForm" action="<?php echo esc_url(home_url('/confirm/')); ?>" method="post" novalidate>
  <label for="uname">お名前 <span class="error-php"><?php echo h($errors_uname); ?></span></label>
  <div class="field">
    <input class="required maxlength" id="uname" name="uname" data-maxlength="30" data-error-required="お名前は必須です。" data-error-maxlength="最大30文字までです。"  value="<?php echo h($uname); ?>" placeholder="お名前(必須)">
  </div>
  <label for="email">メールアドレス <span class="error-php"><?php echo h($errors_email); ?></span></label>
  <div class="field">
    <input class="required pattern" type="email" id="email" name="email" data-pattern="email" data-error-required="Email は必須です。" data-error-pattern="メールアドレスの形式が正しくありません。" value="<?php echo h($email); ?>" placeholder="Email(必須)">
  </div>
  <label for="subject">件名 <span class="error-php"><?php echo h($errors_subject); ?></span></label>
  <div class="field">
    <input class="required maxlength" id="subject" name="subject" data-maxlength="100" data-error-required="件名は必須です。" data-error-maxlength="最大100文字までです。"  value="<?php echo h($subject); ?>" placeholder="件名(必須)">
  </div>
  <label for="body">メッセージ <span class="error-php"><?php echo h($errors_body); ?></span></label>
  <div class="field">
    <textarea class="required maxlength showCount" rows="10" id="body" name="body" data-maxlength="1000" data-error-required="メッセージは必須です。" data-error-maxlength="最大文字数(1000)を超えています。" placeholder="メッセージ(必須 1000 文字まで)" aria-label="Textarea"><?php echo h($body); ?></textarea>
  </div>
  <!-- 確認ページへ渡すトークンの隠しフィールド -->
  <input type="hidden" name="ticket" value="<?php echo h($ticket); ?>">
  <div class="field">
    <button id="confirm" type="submit">確認</button>
  </div>
</form>