「採用サイトの求人情報を、固定ページじゃなくて WordPress の管理画面から更新できるようにしたい」
そんな要件を受けて、カスタム投稿タイプ(CPT)+ タクソノミー + ACF(Advanced Custom Fields)+ 静的テンプレート(PHP) で求人情報システムを構築しました。
プラグインを使えば簡単に実現できる機能ですが、今回は「既存サイトのデザインを維持したまま、データだけ WordPress から取得する」という要件があったため、フルスクラッチで設計しました。
結果として、管理画面での求人管理はもちろん、Google for Jobs(Google しごと検索)対応の構造化データ出力、フロントエンドでの高速な絞り込み機能まで実現できました。
この記事では、その全工程を「実務で本当に使える形」で詳しく解説します。
なぜプラグインを使わなかったのか?
WordPress には優れた求人サイト構築プラグインが多数存在します。
【主要な WordPress 求人プラグイン比較】
| プラグイン名 | 特徴 | 価格 | 向いているケース |
|---|---|---|---|
| WP Job Manager | Automattic製。90,000以上のアクティブインストール。ショートコードベースで汎用性が高い。 | 無料(アドオンは年間29ドル〜) | 汎用的な求人サイト、初心者向け |
| Simple Job Board | クリーンでモダン。不要な肥大化がない。キーワード/ロケーション検索が標準装備。 | 無料 | シンプルな求人掲示板 |
| Job Board Manager | 構造化データ(スキーママークアップ)対応。ジオロケーション機能あり。 | 無料(アドオンあり) | Google for Jobs 対応が必須の場合 |
| WPJobBoard | 強力な検索エンジン機能。有料掲載にも対応。 | 年間97ドル〜 | 本格的な求人ポータルサイト |
しかし、今回のプロジェクトでは以下の理由からプラグインを使いませんでした。
- 既存サイトのデザイン資産を活かしたい
既存の HTML/CSS をそのまま使い、データだけ WordPress から取得する「Headless CMS 的な使い方」が必要だった。 - フロントの URL 構造が独自
/jobs/detail.html?id=123のような既存の URL 構造を維持する必要があった。 - 管理画面のカスタマイズが必要
一覧のカラム表示、プレビューリンク、並び順など、運用に合わせた細かい調整が必要だった。
こうした要件を満たすため、WordPress をデータベース兼管理画面として使い、フロントは独自 PHP テンプレートで構築する方針を選びました。
全体の設計思想
構築した求人システムの全体像は以下の通りです。
【WordPress 側】
├─ カスタム投稿タイプ「求人情報」(job_listing)
├─ タクソノミー(勤務地、職種、採用区分、会社区分、特徴)
├─ ACF でカスタムフィールド(給与、勤務時間、福利厚生など)
└─ 管理画面のカスタマイズ(一覧カラム、プレビューリンク)
【フロント側】
├─ 一覧ページ(index.html ※実体は PHP)
│ ├─ wp-load.php で WordPress を読み込み
│ ├─ WP_Query で全求人を取得
│ ├─ data-* 属性で絞り込み用メタデータを埋め込み
│ └─ JavaScript で絞り込み(フリーワード + 条件)
└─ 詳細ページ(detail.html ※実体は PHP)
├─ GET パラメータ ?id=123 で求人を取得
├─ ACF とタクソノミーで詳細情報を表示
└─ JobPosting 構造化データ(JSON-LD)を出力
この設計により、WordPress の強力な管理機能とフロントエンドの自由度を両立できました。
ステップ1:カスタム投稿タイプの登録
求人を「投稿」や「固定ページ」ではなく、専用の投稿タイプで管理するために、functions.php で register_post_type を実行します。
実装のポイント
supportsはtitle,editor,thumbnailのみ(アイキャッチは一覧・詳細の画像に使用)- 管理画面で並び順を変更できるよう、
menu_orderでソート可能にする show_in_rest => trueにしておくと、ブロックエディタや REST API で扱いやすくなる
登録例(抜粋)
register_post_type('job_listing', array(
'labels' => array(
'name' => '求人情報',
'singular_name' => '求人',
'menu_name' => '求人情報',
'add_new' => '新規追加',
),
'public' => true,
'has_archive' => false,
'show_ui' => true,
'menu_position'=> 5,
'menu_icon' => 'dashicons-businessman',
'supports' => array('title', 'editor', 'thumbnail'),
'show_in_rest' => true,
'rewrite' => array('slug' => 'jobs'),
));
スラッグは rewrite で jobs などにしていますが、今回のようにフロントを独自 URL にしている場合は、リンクは後述の post_type_link フィルターで上書きします。
ステップ2:タクソノミーの登録
求人を「勤務地」「職種」「採用区分」「会社区分」「特徴」で分類できるように、5つのタクソノミーを job_listing に紐づけました。
設計の考え方
【タクソノミー vs カスタムフィールド:パフォーマンスの観点】
頻繁に絞り込みの条件として使用するデータは、カスタムフィールドではなくタクソノミーとして設計することを強く推奨します。
- タクソノミー:WordPress のデータベース内で効率的な検索とフィルタリングのためにインデックス化されている
- カスタムフィールド(meta_query):
wp_postmetaテーブルはpost_idが既知の場合にキーと値を見つけるように最適化されており、meta_valueで検索するようには設計されていない
特にデータ量が多い場合、meta_query による絞り込みは非常に遅くなる傾向があります。長期的に見てタクソノミーの方が優れたパフォーマンスを提供します。
各タクソノミーの役割
- 採用区分:新卒採用 / 中途採用 / パート・アルバイト など。一覧の絞り込みでラジオボタンにする想定なので、1つだけ選択。
- 勤務地・職種・会社区分:一覧・詳細の表示と絞り込み用。運用で「1求人1つ」にしている。
- 特徴:未経験可、駅近、社会保険完備 など。複数選択可。一覧のタグ表示と絞り込みに使用。
いずれも hierarchical => true で登録し、管理画面では「カテゴリーのように」用語を追加・選択できる形にしています。
登録例(勤務地)
register_taxonomy('job_location', 'job_listing', array(
'label' => '勤務地',
'public' => true,
'hierarchical' => true,
'show_ui' => true,
'show_in_rest' => true,
'rewrite' => array('slug' => 'location'),
));
同様に job_type(職種)、recruitment_type(採用区分)、job_company(会社区分)、job_features(特徴)を登録しました。
ステップ3:カスタムフィールド(ACF)の設計
求人詳細で表示する「給与」「勤務地(住所)」「勤務時間」「休日」「選考プロセス」などは、投稿の本文だけでは足りないため、ACF でフィールドを追加しました。
【Advanced Custom Fields (ACF) Pro の基本情報】
- 公式サイト: https://www.advancedcustomfields.com/
- 価格:
- Personal(1サイト): 年間49ドル
- Freelancer(10サイト): 年間149ドル
- Agency(無制限): 年間249ドル
- 主な機能:
- リピーターフィールド(繰り返し可能なサブフィールド)
- フレキシブルコンテンツフィールド(レイアウトマネージャー)
- オプションページ(グローバル設定用)
- ギャラリーフィールド、クローンフィールド
今回は無料版の ACF を使用しましたが、複雑な求人情報(例:複数の勤務地、複数の給与体系)を扱う場合は Pro 版のリピーターフィールドが非常に便利です。
主なフィールド例
- テキスト系:事業所名、短い説明、給与、給与備考、勤務地(住所)、地図リンク、アクセス、勤務時間、休日、学歴・経験、募集人数、試用期間、待遇・福利厚生、喫煙、選考プロセス、情報更新日、問い合わせ電話番号、有効期限 など
- いずれも「求人情報」投稿タイプに紐づくフィールドグループとして定義
テンプレートでの取得
- 詳細テンプレートでは
get_field('short_description', $job_id)のように投稿 ID を第二引数で指定して取得 - 一覧テンプレートでは、ループ内で
get_the_ID()を使い、get_field('short_description', get_the_ID())で取得
給与は一覧カード・詳細・さらに 構造化データ(JobPosting) でも使うため、詳細ページ側で「月給207,000円」「20.7万円」などの表記から数値を抽出する処理を入れています(正規表現で数値だけ取り出し、baseSalary.value に渡す想定)。
ステップ4:管理画面のカスタマイズ(一覧・プレビュー)
運用しやすくするため、以下を追加しました。
一覧のカラム
manage_job_listing_posts_columnsで、タイトルに加えて「採用区分」「勤務地」「職種」「会社区分」「特徴」「日付」を表示manage_job_listing_posts_custom_columnで各列の内容をget_the_terms($post_id, 'taxonomy_name')で出力- 特徴は複数あるため、
wp_list_pluckで名前だけ取り出してカンマ区切りで表示(長い場合は省略)
ソート
manage_edit-job_listing_sortable_columnsで、採用区分・勤務地・職種・会社区分をソート可能に
プレビューリンク
- 管理画面の「プレビュー」を押したときに、WordPress のデフォルトのプレビュー URL ではなく、実際の詳細ページ(例:
/jobs/detail.html?id=123&preview=true)を開くようにpreview_post_linkフィルターで上書き
投稿タイプリンク
- 「投稿を表示」などで使われるパーマリンクを、
post_type_linkフィルターで詳細ページの URL に変更(home_url('/jobs/detail.html?id=' . $post->ID)など)
これで、編集画面から「プレビュー」で実際の詳細デザインを確認しやすくなります。
ステップ5:一覧ページの実装(PHP + 絞り込み)
一覧ページは、静的ディレクトリに置いた PHP ファイル(例:index.html という名前でも中身は PHP)で、次のようにしています。
1. WordPress の読み込み
require_once(__DIR__ . '/../../wordpress/wp-load.php');
2. 絞り込み UI の出力
- フリーワード:
<input>と検索・クリアボタン - 採用区分:
get_terms('recruitment_type')で取得した用語をラジオで出力。値は「新卒」→shinsotsuのように日本語に依存しない値に変換して持たせておく(JS で比較するため) - 勤務地・職種・特徴:
get_terms('job_location')などで取得し、チェックボックスで出力
3. 求人カードの出力
WP_Query で求人を取得し、ループで各求人を <article class="job-card"> で出力。
パフォーマンス最適化のポイント
$args = array(
'post_type' => 'job_listing',
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'menu_order',
'order' => 'ASC',
// パフォーマンス最適化
'no_found_rows' => true, // ページネーション不要なら全体件数の取得をスキップ
);
$query = new WP_Query($args);
【WP_Query パフォーマンス最適化のベストプラクティス】
no_found_rows => true: ページネーションで全体の投稿数を取得する必要がない場合、SQL_CALC_FOUND_ROWSの実行をスキップし、クエリの速度を大幅に向上update_post_meta_cache => false: メタデータを利用しない場合、余分なデータベースクエリを減らすupdate_post_term_cache => false: ターム情報を利用しない場合、メモリ使用量を節約posts_per_page => -1の使用は避ける: 合理的な上限値を設定してメモリ消費を抑えるfields => 'ids': 投稿のIDのみが必要な場合、取得するフィールドを限定
大規模サイトでは、これらの最適化により劇的にパフォーマンスが向上します。
各カードには、絞り込み用の data 属性 を付与:
<article class="job-card"
data-recruitment-type="<?php echo esc_attr($recruitment_type_slug); ?>"
data-location="<?php echo esc_attr($location_slug); ?>"
data-job-type="<?php echo esc_attr($job_type_slug); ?>"
data-company="<?php echo esc_attr($company_slug); ?>"
data-features="<?php echo esc_attr(implode(' ', $feature_slugs)); ?>">
<!-- カードの内容 -->
</article>
4. 絞り込みロジック(JavaScript)
- ラジオ・チェックボックスの変更で
applyFilters()を実行 - 各カードについて、チェックされた条件と
data-*を比較 - フリーワードは、カード内のテキスト(タイトル・会社名・説明・ラベル・給与など)を結合した文字列に対して、スペース区切りのキーワードを AND で検索
- 表示/非表示は
card.style.display = ''と'none'で切り替え、表示件数を更新
サーバー側では「全件出力」し、絞り込みはクライアント側で行う形にすると、追加の API やページネーションを用意しなくてもシンプルに実装できます。
注意点:件数が非常に多い場合(数百件以上)は、初期表示を「最新 N 件」にし、もっと見る or 検索時だけサーバーに送る方式を検討するとよいです。
ステップ6:詳細ページの実装(GET パラメータ・ACF・構造化データ)
詳細ページも同じく、静的ディレクトリ内の PHP ファイル(例:detail.html)で実装しています。
1. 求人 ID の取得と存在チェック
$job_id = isset($_GET['id']) ? intval($_GET['id']) : 0;
$job = get_post($job_id);
if (!$job || $job->post_type !== 'job_listing' || $job->post_status !== 'publish') {
echo '<p>求人が見つかりません。</p>';
exit;
}
2. ACF とタクソノミーの取得
$short_description = get_field('short_description', $job_id);
$salary = get_field('salary', $job_id);
$work_hours = get_field('work_hours', $job_id);
// ... その他のフィールド
$location_terms = get_the_terms($job_id, 'job_location');
$location_name = $location_terms ? $location_terms[0]->name : '';
3. 構造化データ(JobPosting)の出力
Google for Jobs(Google しごと検索)に対応するため、<script type="application/ld+json"> で JobPosting の JSON-LD を出力します。
【Google for Jobs(Google しごと検索)とは】
Google 検索結果に求人情報をリッチリザルトとして表示させる機能です。JobPosting 形式の構造化データを実装することで、検索エンジンが求人情報を正確に理解し、検索結果に表示させることができます。
必須プロパティ:
title(職種名)description(仕事内容)datePosted(公開日)employmentType(雇用形態)hiringOrganization(採用組織)jobLocation(勤務地)
推奨プロパティ:
baseSalary(基本給)validThrough(有効期限)workHours(勤務時間)jobBenefits(福利厚生)
実装後は、Googleの「リッチリザルトテスト」ツールで検証することが重要です。
実装例
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "JobPosting",
"title": "<?php echo esc_js($job->post_title); ?>",
"description": "<?php echo esc_js(wp_strip_all_tags($job->post_content)); ?>",
"identifier": {
"@type": "PropertyValue",
"name": "<?php echo esc_js(get_bloginfo('name')); ?>",
"value": "<?php echo $job_id; ?>"
},
"datePosted": "<?php echo get_the_date('c', $job_id); ?>",
"validThrough": "<?php echo esc_js($valid_through); ?>",
"employmentType": "<?php echo esc_js($employment_type); ?>",
"hiringOrganization": {
"@type": "Organization",
"name": "<?php echo esc_js(get_bloginfo('name')); ?>",
"sameAs": "<?php echo esc_url(home_url('/')); ?>"
},
"jobLocation": {
"@type": "Place",
"address": {
"@type": "PostalAddress",
"addressRegion": "<?php echo esc_js($address_region); ?>",
"addressLocality": "<?php echo esc_js($location_name); ?>"
}
},
"baseSalary": {
"@type": "MonetaryAmount",
"currency": "JPY",
"value": {
"@type": "QuantitativeValue",
"value": <?php echo $salary_value; ?>,
"unitText": "MONTH"
}
}
}
</script>
給与は ACF の文字列から数値を抽出する処理を入れています:
// 「月給207,000円」→ 207000 に変換
preg_match('/[\d,]+/', $salary, $matches);
$salary_value = isset($matches[0]) ? intval(str_replace(',', '', $matches[0])) : 0;
地域(addressRegion)は勤務地の文字列から都道府県を判定する簡易ロジックを入れています(「浜松」→ 静岡県 など)。
ステップ7:リンク・プレビューの統一
投稿のパーマリンク
add_filter('post_type_link', function($post_link, $post) {
if ($post->post_type === 'job_listing') {
return home_url('/jobs/detail.html?id=' . $post->ID);
}
return $post_link;
}, 10, 2);
プレビューリンク
add_filter('preview_post_link', function($preview_link, $post) {
if ($post->post_type === 'job_listing') {
return home_url('/jobs/detail.html?id=' . $post->ID . '&preview=true');
}
return $preview_link;
}, 10, 2);
これで「投稿を表示」や RSS などで使われるリンクが、実際の詳細ページ URL と一致します。
実務で遭遇したハマりポイントと解決策
事件①:タクソノミーのスラッグが日本語のまま
タクソノミーの用語を「新卒採用」「中途採用」のように日本語で登録すると、スラッグも自動的に日本語(URLエンコードされた文字列)になります。
これを JavaScript の絞り込みで使おうとすると、data-recruitment-type="%E6%96%B0%E5%8D%92%E6%8E%A1%E7%94%A8" のようになり、比較が面倒になります。
【解決策】
タクソノミーの用語を登録する際、スラッグを手動で英数字に設定します。
- 名前:「新卒採用」
- スラッグ:
shinsotsu
これで data-recruitment-type="shinsotsu" のようにシンプルに扱えます。
事件②:ACF のフィールド名を間違えて取得できない
get_field('salary') と書いたのに値が取れない…と思ったら、ACF の管理画面で設定したフィールド名が job_salary になっていました。
【解決策】
ACF のフィールド設定画面で「フィールド名」を確認し、テンプレートと一致させます。ラベルと内部名が違う場合は、ドキュメントに書いておくと運用で困りにくいです。
事件③:構造化データのエラーが出る
Google のリッチリザルトテストで「baseSalary.value が必須です」というエラーが出ました。
【原因】
給与フィールドが空の求人があり、$salary_value が 0 になっていました。Google は 0 を「給与情報なし」とみなしてエラーを出します。
【解決策】
給与が空の場合は baseSalary プロパティ自体を出力しないようにしました。
<?php if ($salary_value > 0): ?>
"baseSalary": {
"@type": "MonetaryAmount",
"currency": "JPY",
"value": {
"@type": "QuantitativeValue",
"value": <?php echo $salary_value; ?>,
"unitText": "MONTH"
}
},
<?php endif; ?>
事件④:絞り込みが遅い(パフォーマンス問題)
求人が100件を超えたあたりから、絞り込みの反応が遅くなりました。
【原因】
JavaScript で全カードの data-* 属性を毎回走査していたため、件数が増えるとループ処理が重くなりました。
【解決策】
- 初回ロード時に全カードの情報を配列にキャッシュ
- 絞り込み時はキャッシュされた配列を使って判定
- 表示/非表示の切り替えだけ DOM 操作
これで100件以上でもスムーズに動くようになりました。
まとめ・注意点
-
データの入り口は WordPress
求人の追加・編集・公開/下書きはすべて管理画面で行い、フロントは「表示」専用のテンプレートにしています。 -
一覧の絞り込み
今回は「全件出力 + クライアント側で data 属性を見て表示/非表示」にしました。求人数が増えた場合は、初期表示件数制限やサーバー側検索の検討が必要になるかもしれません。 -
ACF のフィールド名
テンプレートでget_field('xxx')と書いている名前は、ACF のフィールドキー/名前と一致させる必要があります。管理画面のラベルと内部名が違う場合は、ドキュメントに書いておくと運用で困りにくいです。 -
構造化データ
JobPosting の必須項目や推奨項目は、Google のドキュメントに合わせて揃えておくと、リッチリザルト等の扱いが安定しやすいです。 -
既存サイトとの共存
wp-load.phpで読み込む方式だと、既存のルーティングや共通ヘッダー・フッターをそのまま使いながら、求人部分だけ WordPress に寄せることができます。
以上が、WordPress で求人情報システムを構築したときの流れの詳細記録です。同じような「採用サイトの求人を CMS 化したい」ケースの参考になれば幸いです。