Firebase(FCM)を利用した個別送信Webプッシュ通知の実装

ここでは、会員向けWebプッシュ通知を個別送信する仕組みの実装方法について解説します。

本システムではFirebase Cloud Messaging(FCM)を利用し、PHP・JavaScript・データベースを連携させることで、会員IDと通知トークンを紐付け、管理画面からユーザー単位で通知を送信できる構成としています。

ファイル構成

ここでは、会員に対してWebプッシュ通知を個別送信するための基本的なファイル構成を記載しています。

Firebaseの認証キー(firebase-service-account.json)はセキュリティ上、公開ディレクトリ内には配置せず、必ずWeb公開領域の外(1階層上など)に保存し、PHPからパス指定で読み込む構成としています。


admin/kanri/push/
└─ index.php                ← 管理者通知送信画面

phps/push/
├─ send.php                 ← プッシュ送信処理
└─ send_lib.php             ← 共通処理

firebase-messaging-sw.js    ← Service Worker

member/
├─ index.php                ← 会員ログインページ
├─ kanri/push/index.php     ← 通知許可・テスト送信画面
└─ phps/push/token_save.php ← トークン保存処理

(公開ディレクトリ外)
config/
└─ firebase-service-account.json ← FCM認証キー

Firebase(FCM)の設定

Webプッシュ通知を実装するために、Firebase Cloud Messaging(FCM)の設定を行います。

FirebaseはGoogleが提供している通知サービスで、基本的な利用は無料で使用できます。

まず下記のFirebaseコンソールへアクセスし、Googleアカウントでログインします。

https://console.firebase.google.com/

① Firebaseプロジェクトの作成

Firebaseプロジェクトの作成

ログイン後、「新しいFirebaseプロジェクトを作成」をクリックします。

プロジェクト名は任意(英数字記号のみ)で問題ありません。(例:サイト名など)

Firebaseで「Geminiを有効にする」は今回のWebプッシュ通知には不要です。

Google Analyticsは必須ではないため、無効にしプロジェクトを作成します。

② Webアプリの登録

Webアプリの登録

プロジェクト作成後、トップ画面の「アプリを追加」ボタンをクリックします。

表示されるアプリ種類の中から「Web(</>)」を選択してWebアプリを登録します。

アプリ名は任意で設定可能です。(例:サイト名)

「このアプリのFirebase Hostingも設定します」はチェックせず進みます。

「npmを使用する」は選択せず、「<script>タグを使用する」を選択して「コンソールに進む」からWebアプリの登録を行います。

③ Cloud Messagingの有効化

Cloud Messagingの有効化

Firebaseコンソールの左上にある歯車アイコンから全般をクリックします。

設定画面の「Cloud Messaging」タブを選択すると、Web Push証明書(VAPIDキー)の設定項目が表示されます。

「鍵ペアを生成(Generate key pair)」をクリックすると公開鍵と秘密鍵が作成されるため、公開鍵(Public Key)をコピーして控えておきます。

この公開鍵はWebプッシュ通知のJavaScript設定で使用します。

④ サービスアカウントキーの取得

サービスアカウントキーの取得

PHPからFirebase Cloud Messaging(FCM)を利用して通知を送信するために、認証用のサービスアカウントキー(Firebase Admin SDK用)を取得します。

Cloud Messagingと同列のタブにある「サービスアカウント」を開き「新しい秘密鍵を生成」をクリックします。

ダウンロードされるので名前を「firebase-service-account.json」にします。

このファイルはサーバー認証情報そのもののため、外部に公開されないよう厳重に管理する必要があります。

必ず公開ディレクトリ(public_html など)の外に保存します。

保存例
  • config/firebase-service-account.json
  • またはWeb公開ディレクトリの1階層上

※秘密鍵は再ダウンロードできないため、紛失しないよう安全な場所に保存します。

データベース構成

Webプッシュ通知では、会員ごとの通知トークンを管理するためにデータベースを使用します。

本システムでは「通知トークン管理」「会員管理」「送信ログ管理」の3テーブル構成としています。

通知トークン管理(fcm_tokens)

ブラウザごとに発行されるFCMトークンを保存するテーブルです。

会員IDとトークンを紐付けることで、個別ユーザー単位で通知送信が可能になります。


CREATE TABLE `fcm_tokens` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主キー',
  `userid` int(11) NOT NULL COMMENT '会員ID',
  `token` text NOT NULL COMMENT 'FCM通知トークン',
  `updated_at` datetime DEFAULT current_timestamp()
      ON UPDATE current_timestamp() COMMENT '最終更新日時',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_user_token` (`userid`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_general_ci;

通知対象ユーザー管理(fcm_users)

通知対象となるユーザー情報を管理するテーブルです。


CREATE TABLE `fcm_users` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主キー',
  `userid` int(11) NOT NULL COMMENT '会員ID',
  `name` varchar(100) NOT NULL COMMENT '会員名',
  `last_login` datetime DEFAULT NULL COMMENT '最終ログイン日時',
  `active` tinyint(1) DEFAULT 1 COMMENT '通知有効フラグ',
  `created_at` datetime DEFAULT current_timestamp()
      COMMENT '作成日時',
  `updated_at` datetime DEFAULT current_timestamp()
      ON UPDATE current_timestamp() COMMENT '更新日時',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_general_ci;

通知送信ログ(push_logs)

管理画面から送信した通知内容と結果を記録するログテーブルです。

エラー調査や送信履歴確認のために保存します。


CREATE TABLE `push_logs` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主キー',
  `title` varchar(255) DEFAULT NULL COMMENT '通知タイトル',
  `body` text DEFAULT NULL COMMENT '通知本文',
  `url` varchar(500) DEFAULT NULL COMMENT '遷移URL',
  `source` varchar(20) DEFAULT NULL COMMENT '送信元(admin等)',
  `created_at` datetime DEFAULT current_timestamp()
      COMMENT '送信日時',
  `result` longtext DEFAULT NULL COMMENT 'FCM送信結果JSON',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_general_ci;

会員側の通知許可・テスト送信処理

ここでは、会員がブラウザ通知を許可し、FCMトークンを登録する処理と、通知のテスト送信機能について解説します。

通知許可を行うことで、ブラウザごとに発行される通知トークンを取得し、会員IDと紐付けてデータベースへ保存します。

また、正常に通知が届くかを確認するためのテスト送信機能も実装しています。

① 通知許可・テスト送信画面(member/kanri/push/index.php)

会員側の管理画面に通知許可ボタンとテスト送信ボタンを設置します。


<table class="table table-sm">
    <tbody>

        <tr class="clickable-row text-dark">
            <td width="120">①許可設定:</td>
            <td></td>
            <td>
                <button type="button" class="push-enable-btn btn btn-outline-primary btn-sm">
                    通知を許可する
                </button>
            </td>
        </tr>

        <tr class="clickable-row text-dark">
            <td width="120">②送信テスト:</td>
            <td></td>
            <td>
                <button type="button" class="push-test-btn btn btn-info btn-sm">
                    テスト通知
                </button>
            </td>
        </tr>

    </tbody>
</table>

② Firebase JavaScript の読み込み

通知許可・テスト送信画面(member/kanri/push/index.php)にFirebase Messaging を使用するため、Firebase SDK を読み込みます。


<script src="https://www.gstatic.com/firebasejs/10.12.2/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.12.2/firebase-messaging-compat.js"></script>

③ 通知登録・トークン保存処理

通知許可・テスト送信画面(member/kanri/push/index.php)に下記のコードを記載します。

通知許可ボタン押下時にブラウザ通知の権限を取得し、FCMトークンを生成してサーバーへ送信します。

トークンはWAF対策としてBase64変換して送信しています。


<script>
document.addEventListener("DOMContentLoaded", async function() {

    // =========================
    // Firebase 初期化
    // =========================
    if (!firebase.apps.length) {
        firebase.initializeApp({
            apiKey: "<?= FIREBASE_API_KEY ?>",
            authDomain: "<?= FIREBASE_AUTH_DOMAIN ?>",
            projectId: "<?= FIREBASE_PROJECT_ID ?>",
            storageBucket: "<?= FIREBASE_STORAGE_BUCKET ?>",
            messagingSenderId: "<?= FIREBASE_SENDER_ID ?>",
            appId: "<?= FIREBASE_APP_ID ?>"
        });
    }

    const messaging = firebase.messaging();


    // =========================
    // ServiceWorker取得
    // =========================
    async function getSW() {
        let reg = await navigator.serviceWorker.getRegistration('/');
        if (!reg) {
            reg = await navigator.serviceWorker.register(
                '/firebase-messaging-sw.js',
                { scope: '/' }
            );
        }
        return reg;
    }


    // =========================
    // トークン送信(安全版)
    // =========================
    async function sendTokenToServer(token) {

        if (!token) return;

        const formData = new FormData();

        // ★ base64変換(WAF回避の核心)
        const safeToken = btoa(token);

        formData.append("token", safeToken);

        await fetch("/member/phps/push/token_save.php", {
            method: "POST",
            credentials: "same-origin",
            body: formData
        });
    }


    // =========================
    // 自動同期
    // =========================
    async function autoRegisterPush() {

        if (!('Notification' in window)) return;
        if (Notification.permission !== "granted") return;

        try {

            const reg = await getSW();

            await messaging.deleteToken();

            const token = await messaging.getToken({
                vapidKey: "<?= FIREBASE_VAPID_KEY ?>",
                serviceWorkerRegistration: reg
            });

            console.log("AUTO TOKEN:", token);

            if (!token) {
                console.log("AUTO TOKEN 取得失敗");
                return;
            }

            await sendTokenToServer(token);

        } catch (e) {
            console.log("AUTO PUSH ERROR", e);
        }
    }


    // =========================
    // 手動許可ボタン
    // =========================
    document.querySelectorAll(".push-enable-btn").forEach(function(enableBtn) {

        enableBtn.onclick = async function() {

            enableBtn.disabled = true;
            enableBtn.innerText = "登録中…";

            try {

                const permission = await Notification.requestPermission();

                console.log("PERMISSION:", permission);

                if (permission !== "granted") {
                    alert("通知が許可されませんでした");
                    enableBtn.disabled = false;
                    enableBtn.innerText = "通知を許可する";
                    return;
                }

                const reg = await getSW();

                await messaging.deleteToken();

                const token = await messaging.getToken({
                    vapidKey: "<?= FIREBASE_VAPID_KEY ?>",
                    serviceWorkerRegistration: reg
                });

                console.log("MANUAL TOKEN:", token);

                if (!token) {
                    alert("トークン取得に失敗しました。\nChromeの通知設定を確認してください。");
                    enableBtn.disabled = false;
                    enableBtn.innerText = "通知を許可する";
                    return;
                }

                await sendTokenToServer(token);

                enableBtn.innerText = "登録完了 ✓";

                alert("通知登録完了しました");

            } catch (e) {
                console.error(e);
                alert("エラーが発生しました");
                enableBtn.disabled = false;
                enableBtn.innerText = "通知を許可する";
            }
        };

    });


    // =========================
    // テスト送信
    // =========================
    document.querySelectorAll(".push-test-btn").forEach(function(testBtn) {

        testBtn.onclick = async function() {

            const res = await fetch("../../../phps/push/send.php", {
                method: "POST",
                headers: {
                    "Content-Type": "application/json"
                },
                body: JSON.stringify({
                    title: "テスト通知",
                    body: "通知成功しています",
                    url: "https://golf-studio-crown.net/member/",
                    targets: [<?= $_SESSION['USER_ID'] ?>]
                })
            });

            console.log(await res.text());
            alert("送信しました");
        };

    });


    // =========================
    // ページ表示時自動同期
    // =========================
    autoRegisterPush();

});
</script>

③ トークン保存処理(member/phps/push/token_save.php)

ブラウザから取得したFCMトークンをサーバーへ送信し、会員IDと紐付けて保存します。

トークンはWAF対策としてBase64形式で受信し、サーバー側でデコードして登録しています。


<?php
session_start();
require_once $_SERVER['DOCUMENT_ROOT'] . '/db/db.php';

header('Content-Type: application/json');

// =============================
// ログイン確認
// =============================
if (empty($_SESSION['USER_ID'])) {
    http_response_code(403);
    echo json_encode(['error'=>'not logged in']);
    exit;
}

// =============================
// トークン取得
// =============================
$token = base64_decode(trim($_POST['token'] ?? ''));

if (!$token) {
    http_response_code(400);
    echo json_encode(['error'=>'missing token']);
    exit;
}

// =============================
// 基本情報
// =============================
$userid   = intval($_SESSION['USER_ID']);
$username = '会員';


// =============================
// TOKEN登録(重複防止)
// =============================
$sql = "
INSERT INTO fcm_tokens (userid, token, updated_at)
VALUES (:userid, :token, NOW())
ON DUPLICATE KEY UPDATE
    token = VALUES(token),
    updated_at = NOW()
";

execPrep($sql, [
    ':userid'   => $userid,
    ':token'    => $token
]);


// =============================
// USER状態登録
// =============================
$sql2 = "
INSERT INTO fcm_users (userid, name, active, last_login, created_at, updated_at)
VALUES (:userid, :username, 1, NOW(), NOW(), NOW())
ON DUPLICATE KEY UPDATE
    name = VALUES(name),
    active = 1,
    last_login = NOW(),
    updated_at = NOW()
";

execPrep($sql2, [
    ':userid'   => $userid,
    ':username' => $username
]);


// =============================
// 完了レスポンス
// =============================
echo json_encode([
    'status' => 'ok',
    'token_saved' => true
]);

④ 通知送信処理(phps/push/send.php)

管理画面や会員側から受け取った通知内容をもとに、Firebase Cloud Messaging(FCM)を使用して対象ユーザーへプッシュ通知を送信します。

送信結果は push_logs テーブルへ保存し、無効トークンは自動削除される仕組みになっています。


<?php
session_start();
require($_SERVER['DOCUMENT_ROOT'] . '/db/db.php');

header('Content-Type: application/json; charset=utf-8');

$input = json_decode(file_get_contents("php://input"), true);

$title  = $input['title'] ?? 'テスト通知';
$body   = $input['body']  ?? '通知テスト';
$url    = $input['url']   ?? 'https://example.com/';
$source = $input['source'] ?? 'system';
$targets = $input['targets'] ?? [];

if (empty($targets) && isset($_SESSION['USER_ID'])) {
    $targets = [$_SESSION['USER_ID']];
}

if ($source === 'admin' && empty($targets)) {
    echo json_encode(["error"=>"NO TARGETS"]);
    exit;
}


/* =============================
   push_logs 登録
============================= */

$sql = "
INSERT INTO push_logs (
    title, body, url, source, created_at
) VALUES (
    :title,
    :body,
    :url,
    :source,
    NOW()
)
";

execPrep($sql, [
    ':title'  => $title,
    ':body'   => $body,
    ':url'    => $url,
    ':source' => $source
]);

$pushLogId = execPrep("SELECT LAST_INSERT_ID()")->fetchColumn();

$resultLog = [];


/* =============================
   トークン取得
============================= */

if (!empty($targets)) {

    $placeholders = implode(',', array_fill(0, count($targets), '?'));

    $sql = "
        SELECT DISTINCT t.token
        FROM fcm_tokens t
        JOIN fcm_users u ON t.userid = u.userid
        WHERE u.active = 1
        AND u.userid IN ($placeholders)
    ";

    $stmt = execPrep($sql, $targets);

} else {

    $sql = "
        SELECT DISTINCT t.token
        FROM fcm_tokens t
        JOIN fcm_users u ON t.userid = u.userid
        WHERE u.active = 1
    ";

    $stmt = execPrep($sql);
}

$tokens = $stmt->fetchAll(PDO::FETCH_ASSOC);

if (!$tokens) {
    echo json_encode(["error" => "NO TOKENS"]);
    exit;
}


/* =============================
   AccessToken取得
============================= */

$accessToken = getAccessToken();

if (!$accessToken) {
    echo json_encode(["error" => "FCM TOKEN FAILED"]);
    exit;
}


/* =============================
   通知送信
============================= */

foreach ($tokens as $row) {

    $token = $row['token'];
    if (!$token) continue;

    $payload = [
        'message' => [
            'token' => $token,

            'notification' => [
                'title' => $title,
                'body'  => $body
            ],

            'data' => [
                'url' => $url
            ],

            'webpush' => [
                'headers' => ['Urgency' => 'high'],
                'fcm_options' => [
                    'link' => $url
                ],
                'notification' => [
                    'title' => $title,
                    'body'  => $body,
                    'icon'  => 'https://example.com/icon.png'
                ]
            ]
        ]
    ];

    $headers = [
        'Authorization: Bearer ' . $accessToken,
        'Content-Type: application/json'
    ];

    $ch = curl_init(FCM_ENDPOINT);

    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
        CURLOPT_TIMEOUT => 20
    ]);

    $response = curl_exec($ch);

    $http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $decoded = json_decode($response, true);

    $resultLog[] = [
        "token" => $token,
        "http"  => $http,
        "response" => $decoded
    ];
}


/* =============================
   結果保存
============================= */

$sql = "
UPDATE push_logs
SET result = :result
WHERE id = :id
";

execPrep($sql, [
    ':result' => json_encode($resultLog, JSON_UNESCAPED_UNICODE),
    ':id' => $pushLogId
]);


echo json_encode($resultLog, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
exit;



/* =============================
   AccessToken生成
============================= */

function getAccessToken()
{
    $path = '/path/to/config/firebase-service-account.json';

    $keyData = json_decode(file_get_contents($path), true);

    $header = ['alg' => 'RS256', 'typ' => 'JWT'];
    $now = time();

    $claim = [
        'iss'   => $keyData['client_email'],
        'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
        'aud'   => 'https://oauth2.googleapis.com/token',
        'iat'   => $now,
        'exp'   => $now + 3600
    ];

    $jwtHeader = base64url_encode(json_encode($header));
    $jwtClaim  = base64url_encode(json_encode($claim));

    openssl_sign(
        "$jwtHeader.$jwtClaim",
        $signature,
        $keyData['private_key'],
        'sha256WithRSAEncryption'
    );

    $jwt = "$jwtHeader.$jwtClaim." . base64url_encode($signature);

    $post = http_build_query([
        'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
        'assertion'  => $jwt
    ]);

    $ch = curl_init('https://oauth2.googleapis.com/token');

    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $post,
        CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded']
    ]);

    $response = curl_exec($ch);
    curl_close($ch);

    return json_decode($response, true)['access_token'] ?? null;
}


function base64url_encode($data)
{
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

⑤ 動作確認・テスト手順

実装完了後は、下記の手順で動作確認を行います。

  • ① 通知許可ボタンを押してブラウザ通知を許可する
  • ② fcm_tokens テーブルにトークンが登録されているか確認する
  • ③ テスト送信ボタンを押して通知が届くか確認する
  • ④ push_logs テーブルに送信ログが保存されているか確認する

正常に通知が届けば、Webプッシュ通知の設定は完了です。

管理画面からの送信処理

ここでは、管理画面から任意の会員へプッシュ通知を送信する機能について解説します。

送信処理自体は、会員側テスト送信と同じ send.php を使用し、送信対象となる会員IDを指定して通知を配信します。

① 通知送信フォーム(admin画面)

管理画面からタイトル・本文・リンクURLを入力し、通知を送信できるフォームを設置します。


<div class="card-header">
    <h5 class="card-title">プッシュ通知送信</h5>
</div>

<div class="card-body">

    <div class="form-group">
        <label>タイトル</label>
        <input id="pushTitle" class="form-control" placeholder="例)お知らせ">
    </div>

    <div class="form-group">
        <label>本文</label>
        <textarea id="pushBody" class="form-control" rows="3"></textarea>
    </div>

    <div class="form-group">
        <label>リンクURL</label>
        <input id="pushUrl" class="form-control" value="https://example.com/">
    </div>

    <button id="pushSendBtn"
        class="btn btn-info btn-block"
        onclick='return confirm("送信してよろしいですか?");'>
        通知送信
    </button>

    <pre id="pushLog"
        class="mt-3 border rounded p-2 bg-light"
        style="height:150px;overflow:auto;"></pre>

</div>

② 送信対象会員の選択

通知許可済みの会員のみ送信対象として選択できるよう、FCMトークンの有無を基準に一覧を表示します。


<?php
$sql = "
SELECT
    m.member_id,
    m.name,
    m.status,
    COUNT(t.token) AS token_cnt
FROM members m
LEFT JOIN fcm_users u ON u.userid = m.member_id
LEFT JOIN fcm_tokens t ON t.userid = u.userid
GROUP BY m.member_id
ORDER BY m.name
";
$members = execPrep($sql)->fetchAll(PDO::FETCH_ASSOC);
?>

トークン未登録の会員は送信不可として表示しています。

③ 管理画面からの通知送信処理

選択された会員IDを send.php へ送信し、Firebase Cloud Messaging を通して通知を配信します。


<script>

document.getElementById("checkAll").onchange = function() {
    document.querySelectorAll(".targetChk:not(:disabled)")
        .forEach(c => c.checked = this.checked);
};

document.getElementById("pushSendBtn").onclick = async function() {

    const targets = [...document.querySelectorAll(".targetChk:checked")]
        .map(c => c.value);

    if (targets.length === 0) {
        alert("送信対象がありません");
        return;
    }

    const payload = {
        title: pushTitle.value,
        body: pushBody.value,
        url: pushUrl.value,
        source: "admin",
        targets: targets
    };

    const res = await fetch("../../../phps/push/send.php", {
        method: "POST",
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify(payload)
    });

    pushLog.innerText = await res.text();
};

</script>

④ 動作確認・テスト手順(管理画面)

管理画面から通知が正常に送信されるか、下記の手順で確認します。

  • ① 送信対象となる会員に通知許可(トークン登録)がされているか確認する
  • ② 管理画面で送信対象会員を選択する
  • ③ タイトル・本文・URLを入力して通知送信ボタンを押す
  • ④ 対象会員の端末に通知が届くか確認する
  • ⑤ push_logs テーブルに送信結果が保存されているか確認する

正常に通知が届けば、管理画面からのプッシュ通知機能は正常に動作しており実装が完了となります。

ホームページ制作のご依頼・ご相談

お電話でのお問い合わせ
平日 09:00-18:00
メールでのお問い合わせ