2026年9月14日
2026年9月14日
WordPressの「いいね」ボタンをAjaxで実装してデータベースに保存する手順
WordPressに「いいね」ボタンを付けるなら、誰が押したかを専用テーブルに1行ずつ保存し、合計数は投稿メタにまとめて持つ形が、重複を防ぎつつ表示も軽くできる構成です。この記事では、小さなプラグインとしてテーブルを作り、admin-ajax.php 経由で押す・取り消すを切り替え、nonceと入力チェックで安全に保存するまでを順に実装します。最後に、表示されない・保存されないときの確認方法もまとめます。
コードはWordPress 6.3以降を前提にしています(2026年10月現在)。本番に入れる前に、データベースのバックアップを取り、ステージング環境で動作を確認してください。
保存先の設計:なぜ専用テーブルを使うのか
「いいね」の保存方法には、大きく2つの選択肢があります。
投稿メタに合計数だけを保存する方法は手軽ですが、「同じ人が何度も押せる」「取り消しができない」という問題が残ります。さらに、値を読んで1を足して書き戻す処理は、同時に押されると片方の加算が消えることがあります。
ユーザーごとに1行を保存する方法なら、投稿IDとユーザーIDの組み合わせに一意制約(UNIQUE KEY)を付けることで、データベース自身が重複を防いでくれます。取り消しは行を消すだけで済み、「自分がいいねした記事の一覧」も作れます。
この記事では後者を採用し、合計数は表示や並べ替えのために投稿メタ _wpdr_like_count にも保存します。毎回テーブルを数えずに済むので、ページの表示が重くなりません。
また、押せるのはログインユーザーだけにします。ゲストにも開放する場合の考え方は後半で説明します。
プラグインを作ってテーブルを用意する
テーブルの作成はプラグインの有効化時に行うのが自然なので、テーマではなくプラグインとして作ります。wp-content/plugins/wpdr-like/ フォルダを作り、その中に wpdr-like.php を置きます。
<?php
/**
* Plugin Name: WPDR Like Button
* Description: 投稿に「いいね」ボタンを付け、Ajax で専用テーブルに保存する。
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
function wpdr_like_table() {
global $wpdb;
return $wpdb->prefix . 'wpdr_likes';
}
register_activation_hook( __FILE__, function () {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table = wpdr_like_table();
$charset = $wpdb->get_charset_collate();
dbDelta( "CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
user_id bigint(20) unsigned NOT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY post_user (post_id,user_id),
KEY user_id (user_id)
) {$charset};" );
update_option( 'wpdr_like_db_version', '1', false );
} );
dbDelta は書式に厳しい関数です。PRIMARY KEY の後ろに半角スペースを2つ入れる、列ごとに改行する、といった決まりを外すと、テーブルが作られなかったり、更新のたびに変更が繰り返されたりします。上のコードの書式はそのまま使ってください。
プラグインを有効化したら、phpMyAdmin や wp db query "SHOW CREATE TABLE wp_wpdr_likes"(接頭辞はサイトに合わせて変更)でテーブルができているかを確認します。
Ajaxの処理(サーバー側)
続けて同じファイルに、ボタンが押されたときの処理を追加します。wp_ajax_{action} はログインユーザー、wp_ajax_nopriv_{action} はログインしていない人のリクエストで呼ばれるフックです。
add_action( 'wp_ajax_wpdr_like_toggle', 'wpdr_like_toggle' );
add_action( 'wp_ajax_nopriv_wpdr_like_toggle', function () {
wp_send_json_error( array( 'message' => 'いいねするにはログインしてください。' ), 401 );
} );
function wpdr_like_toggle() {
// nonce が正しくなければ、ここで -1 を返して終了する(HTTP 403)
check_ajax_referer( 'wpdr_like', 'nonce' );
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
$post = get_post( $post_id );
if ( ! $post || 'post' !== $post->post_type || 'publish' !== $post->post_status || post_password_required( $post ) ) {
wp_send_json_error( array( 'message' => '対象の記事が見つかりません。' ), 404 );
}
$user_id = get_current_user_id();
// 連打を抑える(同じユーザーは2秒に1回まで)
$lock = 'wpdr_like_lock_' . $user_id;
if ( get_transient( $lock ) ) {
wp_send_json_error( array( 'message' => '少し時間をおいてから操作してください。' ), 429 );
}
set_transient( $lock, 1, 2 );
global $wpdb;
$table = wpdr_like_table();
$liked = (bool) $wpdb->get_var( $wpdb->prepare(
"SELECT 1 FROM {$table} WHERE post_id = %d AND user_id = %d",
$post_id,
$user_id
) );
if ( $liked ) {
$result = $wpdb->delete(
$table,
array( 'post_id' => $post_id, 'user_id' => $user_id ),
array( '%d', '%d' )
);
} else {
// 一意制約に当たった場合(同時押しなど)は何もしない
$result = $wpdb->query( $wpdb->prepare(
"INSERT IGNORE INTO {$table} (post_id, user_id, created_at) VALUES (%d, %d, %s)",
$post_id,
$user_id,
current_time( 'mysql', true )
) );
}
if ( false === $result ) {
wp_send_json_error( array( 'message' => '保存に失敗しました。' ), 500 );
}
$count = (int) $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE post_id = %d",
$post_id
) );
update_post_meta( $post_id, '_wpdr_like_count', $count );
wp_send_json_success( array(
'liked' => ! $liked,
'count' => $count,
) );
}
安全のための3つのポイント
nonceの確認: check_ajax_referer で、リクエストが自サイトのページから送られたものかを確かめます。これが無いと、別のサイトに仕込まれたフォームから、ログイン中の利用者に勝手に「いいね」させること(CSRF)ができてしまいます。
値はすべて検証してからSQLに使う: 投稿IDは absint で整数にし、実在する公開済みの投稿かを確認しています。SQLは $wpdb->prepare のプレースホルダー(%d・%s)で組み立て、送られてきた値を文字列としてSQLに直接埋め込まないようにします。テーブル名は接頭辞から作った固定値なので埋め込んでいますが、WordPress 6.2以降なら識別子用の %i を使う書き方もできます。
合計数は数え直す: 「現在の値に1を足す」のではなく、テーブルの行数を数え直して投稿メタに保存しています。同時に押された場合でも、最終的に正しい数にそろいます。
ボタンの表示とJavaScript
次に、記事本文の最後にボタンを出し、スクリプトを読み込みます。これも同じファイルに追加します。
function wpdr_like_is_liked( $post_id ) {
if ( ! is_user_logged_in() ) {
return false;
}
global $wpdb;
$table = wpdr_like_table();
return (bool) $wpdb->get_var( $wpdb->prepare(
"SELECT 1 FROM {$table} WHERE post_id = %d AND user_id = %d",
$post_id,
get_current_user_id()
) );
}
add_filter( 'the_content', function ( $content ) {
// 抜粋の生成(SEOプラグインの説明文など)に混ざらないようにする
if ( doing_filter( 'get_the_excerpt' ) ) {
return $content;
}
if ( ! is_singular( 'post' ) || get_the_ID() !== get_queried_object_id() ) {
return $content;
}
$post_id = get_the_ID();
$count = (int) get_post_meta( $post_id, '_wpdr_like_count', true );
$button = sprintf(
'<p class="wpdr-like-wrap"><button type="button" class="wpdr-like" data-post-id="%1$d" aria-pressed="%2$s">いいね <span class="wpdr-like-count">%3$d</span></button> <span class="wpdr-like-msg" role="status"></span></p>',
$post_id,
wpdr_like_is_liked( $post_id ) ? 'true' : 'false',
$count
);
return $content . $button;
}, 20 );
add_action( 'wp_enqueue_scripts', function () {
if ( ! is_singular( 'post' ) ) {
return;
}
wp_enqueue_script(
'wpdr-like',
plugins_url( 'like.js', __FILE__ ),
array(),
'1.0.0',
array( 'in_footer' => true, 'strategy' => 'defer' )
);
wp_add_inline_script( 'wpdr-like', 'window.wpdrLike = ' . wp_json_encode( array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'wpdr_like' ),
'loggedIn' => is_user_logged_in(),
'loginUrl' => wp_login_url( get_permalink() ),
) ) . ';', 'before' );
} );
同じフォルダに like.js を作ります。
(function () {
'use strict';
var cfg = window.wpdrLike;
if (!cfg) { return; }
document.addEventListener('click', function (e) {
var btn = e.target.closest('.wpdr-like');
if (!btn || btn.disabled) { return; }
if (!cfg.loggedIn) {
window.location.href = cfg.loginUrl;
return;
}
var msg = btn.parentNode.querySelector('.wpdr-like-msg');
var body = new FormData();
body.append('action', 'wpdr_like_toggle');
body.append('nonce', cfg.nonce);
body.append('post_id', btn.dataset.postId);
btn.disabled = true;
fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: body })
.then(function (r) {
return r.json().catch(function () {
// nonce エラー時は JSON ではなく -1 が返る
return { success: false, data: { message: '通信に失敗しました(' + r.status + ')。ページを再読み込みしてください。' } };
});
})
.then(function (res) {
if (!res.success) {
throw new Error((res.data && res.data.message) || '保存できませんでした。');
}
btn.setAttribute('aria-pressed', String(res.data.liked));
btn.querySelector('.wpdr-like-count').textContent = res.data.count;
if (msg) { msg.textContent = ''; }
})
.catch(function (err) {
if (msg) { msg.textContent = err.message; }
})
.finally(function () {
btn.disabled = false;
});
});
})();
ボタンには aria-pressed を付け、押されている状態をスクリーンリーダーにも伝えています。見た目もこの属性に合わせてCSSで切り替えると(例: .wpdr-like[aria-pressed="true"] の色を変える)、状態の管理が1か所で済みます。処理中はボタンを無効にして、二重送信を防いでいます。
the_content フィルターの中で doing_filter( 'get_the_excerpt' ) を確認しているのは、抜粋を自動生成する処理も the_content を通るためです。これを入れないと、SEOプラグインが作る説明文に「いいね 3」のような文字が混ざることがあります。
ゲストにも押せるようにする場合の考え方
ログインしていない人にも開放したい場合は、wp_ajax_nopriv_ 側にも処理を書くことになりますが、次の点で難しさが増します。
- 誰が押したかの判定: Cookieは削除すれば何度でも押せますし、IPアドレスは同じ回線の複数人を区別できません。IPアドレスを使う場合はそのまま保存せず、
wp_salt()と組み合わせたハッシュにするなど、個人データとしての扱いに配慮し、プライバシーポリシーにも記載します。 - ページキャッシュとnonce: ゲスト向けのページはキャッシュされることが多く、HTMLに埋め込んだnonceが古くなって検証に失敗します(nonceの有効期間はおおむね12〜24時間です)。nonceを別のリクエストで取得し直すなどの工夫が必要です。
- 集計の信頼性: ゲストの「いいね」は簡単に水増しできます。数字を人気ランキングなどに使うなら、その前提で扱います。
こうした手間を考えると、ゲスト対応が欠かせない場合は、WP ULike のような専用プラグインの導入も選択肢です(機能や無料版・有料版の範囲は変わるため、導入前に公式の説明を確認してください)。
動かないときの確認方法
ブラウザの開発者ツールの「ネットワーク」タブで admin-ajax.php へのリクエストを開き、ステータスコードと応答を見るのが一番の近道です。
- **応答が
0(HTTP 400)**: 指定したactionに対応するフックが登録されていません。プラグインが有効か、actionの値(wpdr_like_toggle)とフック名の綴りが一致しているかを確認します。 - **応答が
-1(HTTP 403)**: nonceの検証に失敗しています。ページキャッシュで古いnonceが配信されていないか、wp_create_nonceとcheck_ajax_refererの名前(wpdr_like)が一致しているかを確認します。 - HTTP 500で「保存に失敗しました」: テーブルが存在しない可能性があります。プラグインを一度停止して再度有効化し、テーブルが作られたかを確認します。
WP_DEBUG_LOGを有効にして、wp-content/debug.logにデータベースのエラーが出ていないかも見ます。 - ボタンが表示されない: テーマが
the_contentを通さずに本文を出力している、または投稿タイプがpost以外の可能性があります。カスタム投稿タイプで使う場合は条件の'post'を書き換えます。 - 数は増えるのに再読み込みで戻る: ページキャッシュが古いHTMLを返しています。ログインユーザーをキャッシュの対象外にする設定になっているかを確認します。
なお、プラグインを削除するときにテーブルも消したい場合は、uninstall.php を用意して DROP TABLE と投稿メタの削除(delete_post_meta_by_key)を行います。データが消えるので、実行前にバックアップを取ってください。
まとめ
- 「いいね」はユーザーごとに1行を専用テーブルに保存し、一意制約で重複を防ぎます。合計数は投稿メタに保存して表示を軽くします。
- テーブルはプラグインの有効化時に
dbDeltaで作ります。書式の決まりに注意します。 - Ajaxの処理では
check_ajax_refererでnonceを確認し、投稿IDの検証と$wpdb->prepareで安全に保存します。 - ボタンは
aria-pressedで状態を伝え、処理中は無効にして二重送信を防ぎます。 - 動かないときは、
admin-ajax.phpの応答(0、-1、500)から原因を切り分けます。