2026年6月27日
2026年6月27日
WordPressでWeb通知(Notifications API・Push API)を使い購読ユーザーに新着記事を知らせる実装
ブラウザを閉じている読者にも「新しい記事を公開しました」と届けるには、通知を表示するNotifications APIだけでは足りず、Service WorkerとPush APIで購読情報を受け取り、記事の公開時にサーバーから送信する仕組みが必要です。この記事では、外部の通知サービスを使わずにWordPressのプラグインとして組む方法を、購読の受け付け、公開時の送信、無効になった購読の掃除、プライバシーの配慮まで順に解説します。手軽さを優先するなら通知サービスのプラグインを使う選択肢もあるので、最初に両者の違いから整理します。
2つのAPIの役割と、作る前の判断
- Notifications API: 画面に通知を表示する仕組みです。読者の許可(
Notification.requestPermission())が要ります。ページを開いている間だけなら、これだけで通知を出せます。 - Push API: サーバーからブラウザのプッシュサービスへメッセージを送り、ページを閉じていてもService Workerを起こして通知を出させる仕組みです。「購読ユーザーへの更新通知」はこちらが本体です。
実現方法は大きく2つです。
- 通知サービスのプラグインを使う: OneSignalなどのサービスが公式のWordPressプラグインを提供しており、購読の管理や送信はサービス側が担います。購読者の情報が外部に預けられること、無料で使える範囲や規約が変わりうることを確認してから選びます。
- 自前で組む: 購読情報を自分のデータベースに置き、PHPのライブラリ(
minishlink/web-push)で送信します。外部サービスへの依存はなくなりますが、運用と保守は自分の責任になります。
この記事は後者の手順です。いずれの方法でも、プッシュ通知はHTTPSのサイトでしか使えません。
対応環境を確認する
Chrome・Edge・Firefoxなどのデスクトップ版とAndroid版は、Web Push に対応しています。AppleのSafariは、macOSでは比較的新しいバージョンから、iPhoneとiPadではiOS・iPadOS 16.4以降で、ホーム画面に追加したWebアプリとして開いたときだけ通知を受け取れます。つまりiPhoneの読者に届けるには、マニフェストを用意して「ホーム画面に追加」してもらう必要があります。対応状況は変わり続けているため(2026年10月現在)、導入前にMDNなどで確認してください。
準備:ライブラリとVAPIDの鍵
作業は本番ではなくステージング環境で行い、データベースのバックアップを取ってから始めます。
wp-content/plugins/my-web-push/を作り、そこでcomposer require minishlink/web-pushを実行します。ライブラリが求めるPHPのバージョンと拡張機能(openssl、curl、mbstringなど)は、バージョンによって変わるため、ライブラリのREADMEで確認します。- 送信元を証明するVAPIDの鍵ペアを作ります。ライブラリの
VAPID::createVapidKeys()で生成できます。
cd wp-content/plugins/my-web-push
php -r 'require "vendor/autoload.php"; print_r(Minishlink\WebPush\VAPID::createVapidKeys());'
- 生成された値を
wp-config.phpに定数として書きます。秘密鍵はデータベースやGitに入れず、画面にも表示しないでください。
// wp-config.php(値はダミー。生成した値に置き換える)
define( 'MWP_VAPID_PUBLIC_KEY', 'your-vapid-public-key' );
define( 'MWP_VAPID_PRIVATE_KEY', 'your-vapid-private-key' );
define( 'MWP_VAPID_SUBJECT', 'mailto:admin@example.com' );
プラグイン本体:購読の保存と受け付け
購読情報は専用のテーブルに保存します。受け付けのREST APIは未ログインの読者も使うため、ログインのチェックはせず、その代わりに入力の検証と回数制限、送信先ホストの制限をかけます。送信先を制限しないと、悪意ある人が任意のURLを登録し、サイトのサーバーからそのURLへリクエストを送らせる踏み台にされるおそれがあるためです。
<?php
/**
* Plugin Name: My Web Push
* Description: 新着記事を購読者にWeb Pushで通知する
*/
defined( 'ABSPATH' ) || exit;
require __DIR__ . '/vendor/autoload.php';
use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;
function mwp_table() {
global $wpdb;
return $wpdb->prefix . 'mwp_subscriptions';
}
register_activation_hook( __FILE__, function () {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table = mwp_table();
$charset = $wpdb->get_charset_collate();
dbDelta( "CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
endpoint_hash char(64) NOT NULL,
endpoint text NOT NULL,
p256dh varchar(255) NOT NULL,
auth varchar(255) NOT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY endpoint_hash (endpoint_hash)
) {$charset};" );
} );
function mwp_is_allowed_endpoint( $url ) {
if ( strlen( $url ) > 1000 || ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
return false;
}
if ( 'https' !== wp_parse_url( $url, PHP_URL_SCHEME ) ) {
return false;
}
$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
$hosts = apply_filters( 'mwp_allowed_push_hosts', array(
'fcm.googleapis.com',
'updates.push.services.mozilla.com',
'web.push.apple.com',
'.notify.windows.com',
) );
foreach ( $hosts as $allowed ) {
if ( $host === $allowed || ( '.' === $allowed[0] && str_ends_with( $host, $allowed ) ) ) {
return true;
}
}
return false;
}
function mwp_rate_limited() {
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$key = 'mwp_rl_' . md5( $ip );
$count = (int) get_transient( $key );
if ( $count >= 10 ) {
return true;
}
set_transient( $key, $count + 1, HOUR_IN_SECONDS );
return false;
}
add_action( 'rest_api_init', function () {
register_rest_route( 'mwp/v1', '/subscribe', array(
'methods' => 'POST',
'callback' => 'mwp_rest_subscribe',
'permission_callback' => '__return_true', // 未ログインの読者も購読できる
) );
register_rest_route( 'mwp/v1', '/unsubscribe', array(
'methods' => 'POST',
'callback' => 'mwp_rest_unsubscribe',
'permission_callback' => '__return_true',
) );
} );
function mwp_rest_subscribe( WP_REST_Request $request ) {
if ( mwp_rate_limited() ) {
return new WP_Error( 'mwp_rate_limited', 'Too many requests.', array( 'status' => 429 ) );
}
$endpoint = (string) $request->get_param( 'endpoint' );
$keys = (array) $request->get_param( 'keys' );
$p256dh = isset( $keys['p256dh'] ) ? (string) $keys['p256dh'] : '';
$auth = isset( $keys['auth'] ) ? (string) $keys['auth'] : '';
if ( ! mwp_is_allowed_endpoint( $endpoint )
|| ! preg_match( '/^[A-Za-z0-9_=-]{80,100}$/', $p256dh )
|| ! preg_match( '/^[A-Za-z0-9_=-]{20,30}$/', $auth ) ) {
return new WP_Error( 'mwp_invalid', 'Invalid subscription.', array( 'status' => 400 ) );
}
global $wpdb;
$wpdb->replace(
mwp_table(),
array(
'endpoint_hash' => hash( 'sha256', $endpoint ),
'endpoint' => $endpoint,
'p256dh' => $p256dh,
'auth' => $auth,
'created_at' => current_time( 'mysql', true ),
),
array( '%s', '%s', '%s', '%s', '%s' )
);
return rest_ensure_response( array( 'ok' => true ) );
}
function mwp_rest_unsubscribe( WP_REST_Request $request ) {
global $wpdb;
$endpoint = (string) $request->get_param( 'endpoint' );
$wpdb->delete( mwp_table(), array( 'endpoint_hash' => hash( 'sha256', $endpoint ) ), array( '%s' ) );
return rest_ensure_response( array( 'ok' => true ) );
}
p256dh と auth は、ブラウザが作る暗号化用の鍵で、長さがほぼ決まっています。正規表現で形を確かめているのはそのためです。プッシュサービスのホスト名は各社の都合で変わる可能性があるので、届かないブラウザが出たら、購読時のエンドポイントのホストを確認し、mwp_allowed_push_hosts フィルターで追加します。
記事の公開時に送信する
送信は transition_post_status フックで「公開になった瞬間」を捉え、その場では送らずにWP-Cronへ回します。購読者が多いと送信に時間がかかり、投稿画面の保存が遅くなるためです。
// 同じプラグインファイルの続き
add_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {
if ( 'publish' !== $new_status || 'publish' === $old_status || 'post' !== $post->post_type ) {
return;
}
if ( ! wp_next_scheduled( 'mwp_send_push', array( $post->ID ) ) ) {
wp_schedule_single_event( time() + 60, 'mwp_send_push', array( $post->ID ) );
}
}, 10, 3 );
add_action( 'mwp_send_push', function ( $post_id ) {
if ( ! defined( 'MWP_VAPID_PRIVATE_KEY' ) ) {
return;
}
$post = get_post( $post_id );
if ( ! $post || 'publish' !== $post->post_status || '' !== $post->post_password ) {
return;
}
$payload = wp_json_encode( array(
'title' => wp_strip_all_tags( get_the_title( $post ) ),
'body' => wp_trim_words( wp_strip_all_tags( get_the_excerpt( $post ) ), 40, '…' ),
'url' => get_permalink( $post ),
) );
$web_push = new WebPush( array(
'VAPID' => array(
'subject' => MWP_VAPID_SUBJECT,
'publicKey' => MWP_VAPID_PUBLIC_KEY,
'privateKey' => MWP_VAPID_PRIVATE_KEY,
),
) );
global $wpdb;
$last_id = 0;
do {
$rows = $wpdb->get_results( $wpdb->prepare(
'SELECT id, endpoint, p256dh, auth FROM %i WHERE id > %d ORDER BY id LIMIT 500',
mwp_table(),
$last_id
) );
foreach ( $rows as $row ) {
$last_id = (int) $row->id;
$web_push->queueNotification(
Subscription::create( array(
'endpoint' => $row->endpoint,
'publicKey' => $row->p256dh,
'authToken' => $row->auth,
'contentEncoding' => 'aes128gcm',
) ),
$payload
);
}
foreach ( $web_push->flush() as $report ) {
if ( $report->isSubscriptionExpired() ) {
$wpdb->delete( mwp_table(), array( 'endpoint_hash' => hash( 'sha256', $report->getEndpoint() ) ), array( '%s' ) );
}
}
} while ( count( $rows ) === 500 );
} );
押さえておきたい点は次のとおりです。
- 公開済みの記事を更新したときは送らない:
'publish' === $old_statusのときに抜けているので、誤字の修正で通知が飛ぶことはありません。予約投稿が公開されたときは送られます。 - パスワード保護の記事は送らない: 本文の一部が通知に出るのを防ぎます。
- 無効になった購読を消す: 読者が通知をオフにしたりブラウザのデータを消したりすると、送信結果が「期限切れ」になります。残すと送信の無駄が増えるため、その場で削除します。
- 通知の中身は短く: 送れるデータの大きさには上限があります。タイトル・短い要約・URLにとどめます。
- **
%iプレースホルダー**: テーブル名を安全に埋め込む書式で、WordPress 6.2以降で使えます。
WP-Cronはサイトへのアクセスをきっかけに動くため、アクセスが少ないと送信が遅れます。サーバーのcronから wp cron event run --due-now を定期的に実行する形にすると、送信のタイミングが安定します。
Service Workerと購読ボタン
Service Workerはサイトのトップのパスから配信しないと、サイト全体を制御できません。プラグインの中で、クエリ付きのURLにスクリプトを返す形にします。購読ボタンはショートコード [mwp_subscribe] で好きな場所に置けるようにします。
// 同じプラグインファイルの続き
add_filter( 'query_vars', function ( $vars ) {
$vars[] = 'mwp_sw';
return $vars;
} );
add_action( 'template_redirect', function () {
if ( '1' !== get_query_var( 'mwp_sw' ) ) {
return;
}
header( 'Content-Type: application/javascript; charset=utf-8' );
header( 'Cache-Control: no-cache' );
echo 'const MWP_HOME = ' . wp_json_encode( home_url( '/' ) ) . ";\n";
readfile( __DIR__ . '/sw.js' );
exit;
}, 0 );
add_shortcode( 'mwp_subscribe', function () {
if ( ! defined( 'MWP_VAPID_PUBLIC_KEY' ) ) {
return '';
}
wp_enqueue_script( 'mwp-subscribe', plugins_url( 'subscribe.js', __FILE__ ), array(), '1.0.0', array( 'strategy' => 'defer', 'in_footer' => true ) );
wp_add_inline_script( 'mwp-subscribe', 'const MWP = ' . wp_json_encode( array(
'swUrl' => home_url( '/?mwp_sw=1' ),
'restUrl' => rest_url( 'mwp/v1/' ),
'publicKey' => MWP_VAPID_PUBLIC_KEY,
) ) . ';', 'before' );
return '<p><button type="button" id="mwp-subscribe" hidden>' . esc_html__( '新着記事の通知を受け取る', 'my-web-push' ) . '</button></p>'
. '<p id="mwp-status" aria-live="polite"></p>';
} );
// sw.js(プラグインのフォルダに置く。先頭に PHP が MWP_HOME を出力します)
self.addEventListener('push', (event) => {
let data = {};
try { data = event.data ? event.data.json() : {}; } catch (e) {}
event.waitUntil(self.registration.showNotification(data.title || '新しい記事を公開しました', {
body: data.body || '',
data: { url: data.url || MWP_HOME },
}));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = new URL(event.notification.data.url, MWP_HOME);
if (url.origin !== self.location.origin) return; // 自サイト以外は開かない
event.waitUntil(clients.openWindow(url.href));
});
// subscribe.js(プラグインのフォルダに置く)
(() => {
const btn = document.getElementById('mwp-subscribe');
const status = document.getElementById('mwp-status');
if (!btn || !('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return;
btn.hidden = false;
const toUint8 = (b64) => {
const s = atob((b64 + '='.repeat((4 - b64.length % 4) % 4)).replace(/-/g, '+').replace(/_/g, '/'));
return Uint8Array.from(s, (c) => c.charCodeAt(0));
};
btn.addEventListener('click', async () => {
if (Notification.permission === 'denied') {
status.textContent = '通知がブロックされています。ブラウザのサイト設定から許可してください。';
return;
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
try {
await navigator.serviceWorker.register(MWP.swUrl);
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: toUint8(MWP.publicKey),
});
const res = await fetch(MWP.restUrl + 'subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub.toJSON()),
});
status.textContent = res.ok ? '通知の登録が完了しました。' : '登録に失敗しました。時間をおいてお試しください。';
} catch (e) {
status.textContent = '登録に失敗しました。';
}
});
})();
許可を求めるのは、読者がボタンを押したときに限ります。これは配慮であると同時に、ブラウザ側の制約でもあります。Safariなどは読者の操作の中でしか許可の確認を出せず、ページを開いた直後に許可を求めると、ブラウザによっては表示自体が抑えられたり、ブロックされる割合が増えたりします。一度「ブロック」されると、サイトから再び許可を求めることはできず、読者がブラウザの設定から変えるしかありません。
購読をやめる操作も用意しておきます。reg.pushManager.getSubscription() で現在の購読を取り出し、unsubscribe() を呼んだうえで、そのエンドポイントを mwp/v1/unsubscribe に送れば、サーバー側の記録も消えます。
プライバシーと運用の注意
購読のエンドポイントは、特定のブラウザに通知を届けるための識別子です。氏名やメールアドレスではありませんが、個人に関する情報として扱い、プライバシーポリシーに「通知のために購読情報を保存すること」「解除の方法」を書き加えます。この実装では、回数制限のためにIPアドレスのハッシュを1時間だけ一時データとして保存していますが、購読情報そのものにはIPアドレスを残していません。
通知は送りすぎると解除されます。記事の公開ごとに1回にとどめ、テスト送信はステージング環境の購読者に対して行ってください。通知が届かないときは、デベロッパーツールの「Application」パネルでService Workerが動いているか、通知の許可状態、サーバーのPHPエラーログ、wp cron event list で予定が残っていないかを順に確認します。
まとめ
- 購読ユーザーへの更新通知には、Notifications APIに加えてService WorkerとPush APIが必要です。
- 外部サービスのプラグインは手軽ですが、購読情報を預けることになります。自前なら
minishlink/web-pushとVAPIDの鍵で組めます。 - 受け付けのREST APIは、入力の検証・回数制限・送信先ホストの制限で守ります。
- 送信は
transition_post_statusで公開の瞬間を捉え、WP-Cronで分割して行い、期限切れの購読は削除します。 - iPhone・iPadはホーム画面に追加したWebアプリだけが対象で、許可は読者のボタン操作で求めます。