🌊

【ハンズオン】LaravelでWebプッシュ通知を実装する

に公開

はじめに

「ブラウザのタブを閉じていても、デスクトップやスマホの通知センターに表示されるあの通知」を、自分のLaravelアプリから送れるようにします。Firebaseのような外部サービスは使いません。

Webプッシュ通知は、W3Cで標準化されたオープンな仕組みです。Chrome・Firefox・Edge・Safariのすべての主要ブラウザが対応しています。iOSも16.4以降でWebプッシュをサポートしました。

この記事では、laravel-notification-channels/webpush パッケージを使って、Laravelの標準通知システム(Notificationクラス)にWebプッシュチャンネルを追加します。via(['mail']) と書く感覚で via([WebPushChannel::class]) と書けるようになる、ということです。

この記事の対象読者

  • Laravelの基本的なCRUDが書ける方
  • 通知機能を実装したいが、FirebaseやPusherの導入は避けたい方
  • Service WorkerやPush APIをはじめて触る方

このハンズオンを通して、「通知を許可する」ボタンの実装からTinkerでの送信確認まで、ひととおり手元で動かせるようになります。

Webプッシュ通知の仕組み

最初に全体像を押さえておきます。登場人物は4つです。

  1. ブラウザ:ユーザーが通知を許可し、Push APIで購読情報を受け取る
  2. プッシュサービス:各ブラウザベンダーが運用する中継サーバー(Chromeならfcm.googleapis.com、Firefoxならupdates.push.services.mozilla.com
  3. アプリサーバー(Laravel):通知を送信したいタイミングでプッシュサービスにリクエストを送る
  4. Service Worker:バックグラウンドで動き、プッシュイベントを受け取って実際の通知を表示する

VAPIDキーは「このアプリサーバーは正規の送信者ですよ」をプッシュサービスに証明するための公開鍵・秘密鍵ペアです。サーバー側が秘密鍵で署名し、ブラウザは公開鍵で検証します。

完成イメージ

実装が終わると、こんな動きになります。

  1. ユーザーが画面の「通知を許可する」ボタンを押す
  2. ブラウザのネイティブな通知許可ダイアログが出る
  3. 許可後、購読情報がサーバーに保存される
  4. Tinkerから $user->notify(new TestNotification) を実行
  5. ブラウザのタブを閉じていても、OSの通知センターにアプリからの通知が出る

環境

項目 バージョン
Laravel 11.x / 12.x / 13.x
PHP 8.2 以上
パッケージ laravel-notification-channels/webpush ^10.5

ローカルではLaravelの開発サーバー(php artisan serve)でそのまま動きます。

実装手順

1. パッケージのインストール

Composerでパッケージを追加します。

composer require laravel-notification-channels/webpush

2. マイグレーションと設定ファイルの公開

購読情報を保存するテーブル(push_subscriptions)を作るためのマイグレーションと、設定ファイルを公開します。

php artisan vendor:publish --provider="NotificationChannels\WebPush\WebPushServiceProvider" --tag="migrations"
php artisan vendor:publish --provider="NotificationChannels\WebPush\WebPushServiceProvider" --tag="config"
php artisan migrate

config/webpush.php が生成され、database/migrations/ に新しいマイグレーションが追加されます。

3. VAPIDキーの生成

パッケージに付属の専用コマンドでVAPIDキーペアを生成します。

php artisan webpush:vapid

.env に以下のように追記されます。

VAPID_PUBLIC_KEY=BNc...省略...
VAPID_PRIVATE_KEY=xyz...省略...

iOS/Safariも対象にする場合は、VAPID_SUBJECT も必ず追加してください。Appleはこれが無いと BadJwtToken エラーで拒否します。

VAPID_SUBJECT=mailto:admin@example.com

VAPID_SUBJECT は、プッシュサービス事業者(Google・Mozilla・Apple)向けのアプリ運営者の連絡先です。送信される各プッシュリクエストのJWTに含まれ、配信量の異常や仕様違反などがあった際にプッシュサービス側から連絡を受け取るためのアドレスとして使われます。

エンドユーザー(通知を受け取る人)には表示されません。設定するのは実際にメールが届いて、自分または運営チームが確認できるアドレスにしてください。架空のアドレスでも仕様上は通りますが、本番運用では問題発生時に気づけなくなります。mailto: の代わりに https://example.com/contact のような連絡用URLでも構いません。

4. Userモデルにトレイトを追加

app/Models/User.phpHasPushSubscriptions トレイトを追加します。これでユーザーが購読情報を持てるようになります。

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use NotificationChannels\WebPush\HasPushSubscriptions;

class User extends Authenticatable
{
    use Notifiable, HasPushSubscriptions;

    // ... 既存のコードはそのまま
}

これで $user->updatePushSubscription(...)$user->pushSubscriptions といったメソッド・リレーションが使えるようになります。

5. サブスクリプション登録用のルートとコントローラ

ブラウザから受け取った購読情報をDBに保存するエンドポイントを作ります。

まず、ルートを routes/web.php に追加します。

use App\Http\Controllers\PushSubscriptionController;

Route::middleware('auth')->group(function () {
    Route::post('/push/subscribe', [PushSubscriptionController::class, 'store']);
    Route::delete('/push/subscribe', [PushSubscriptionController::class, 'destroy']);
});

次に、コントローラを作成します。

php artisan make:controller PushSubscriptionController

中身は以下のとおりです。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class PushSubscriptionController extends Controller
{
    public function store(Request $request)
    {
        $validated = $request->validate([
            'endpoint'    => 'required|string',
            'key'         => 'required|string',
            'token'       => 'required|string',
            'encoding'    => 'nullable|string',
        ]);

        $request->user()->updatePushSubscription(
            $validated['endpoint'],
            $validated['key'],
            $validated['token'],
            $validated['encoding'] ?? null,
        );

        return response()->json(['success' => true]);
    }

    public function destroy(Request $request)
    {
        $validated = $request->validate([
            'endpoint' => 'required|string',
        ]);

        $request->user()->deletePushSubscription($validated['endpoint']);

        return response()->json(['success' => true]);
    }
}

updatePushSubscription() は同じエンドポイントが既にあれば更新、なければ新規作成、を自動で判定してくれます。重複を気にしなくて大丈夫です。

6. 通知クラスの作成

実際に送る通知本体を作ります。今回はテスト用に「テスト通知」というシンプルな通知を作ります。

php artisan make:notification TestNotification

app/Notifications/TestNotification.php を以下のように編集します。

<?php

namespace App\Notifications;

use Illuminate\Notifications\Notification;
use NotificationChannels\WebPush\WebPushChannel;
use NotificationChannels\WebPush\WebPushMessage;

class TestNotification extends Notification
{
    public function __construct(
        protected string $title = 'テスト通知',
        protected string $body = 'Laravelからの通知が届きました 🎉',
    ) {}

    public function via(object $notifiable): array
    {
        return [WebPushChannel::class];
    }

    public function toWebPush(object $notifiable, $notification): WebPushMessage
    {
        return (new WebPushMessage)
            ->title($this->title)
            ->icon('/icon-192.png')   // 任意のアイコン画像のパス
            ->body($this->body)
            ->action('開く', 'open_app')
            ->data(['url' => '/dashboard'])
            ->options(['TTL' => 600]); // 10分以内に配送できなければ破棄
    }
}

WebPushMessage で設定できる主要なメソッドは以下のとおりです。

メソッド 説明
title() 通知のタイトル
body() 本文
icon() 左側に表示されるアイコン画像のURL
badge() モノクロのバッジアイコン(Androidで使用)
image() 通知内に大きく表示する画像
action() アクションボタン(複数追加可)
data() クライアントに渡す任意のデータ
tag() 同タグの通知をまとめる
requireInteraction() ユーザー操作があるまで通知を消さない
options(['TTL' => 秒]) 配送猶予時間

7. Service Workerの作成

Service Workerは、ブラウザがバックグラウンドでも動かせるJavaScriptです。プッシュイベントを受け取って通知を表示する役割を担います。

public/sw.js を新規作成します。

public/sw.js
// プッシュイベントを受信したときの処理
self.addEventListener('push', function (event) {
    let data = {};

    try {
        data = event.data?.json() ?? {};
    } catch (e) {
        // JSONでない場合はテキストとして扱う
        data = { title: '通知', body: event.data?.text() ?? '' };
    }

    event.waitUntil(
        self.registration.showNotification(data.title, {
            body: data.body,
            icon: data.icon,
            badge: data.badge,
            data: data.data,
            actions: data.actions,
        })
    );
});

// 通知がクリックされたときの処理
self.addEventListener('notificationclick', function (event) {
    event.notification.close();

    const url = event.notification.data?.url ?? '/';

    event.waitUntil(
        self.clients
            .matchAll({ type: 'window', includeUncontrolled: true })
            .then(function (clientList) {
                // 既に同じURLのタブが開いていればフォーカスする
                for (const client of clientList) {
                    if (client.url === url && 'focus' in client) {
                        return client.focus();
                    }
                }
                // なければ新規タブで開く
                return self.clients.openWindow(url);
            })
    );
});

8. フロントエンド(購読ボタン)の実装

Blade テンプレートに、購読ボタンと必要なJavaScriptを追加します。resources/views/dashboard.blade.php などに以下を追記してください。

まず、<head> 内でVAPIDの公開鍵をJavaScriptから参照できるようにします。

<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="vapid-public-key" content="{{ config('webpush.vapid.public_key') }}">

次に、購読ボタンと制御用のJavaScriptを追加します。

<div class="m-3 flex flex-col gap-3">
    <button id="subscribe-btn" type="button" class="p-2 rounded bg-blue-300 hover:opacity-80">通知を許可する</button>
    <button id="unsubscribe-btn" type="button" class="p-2 rounded bg-gray-300 hover:opacity-80">通知を解除する</button>
</div>

<script>
// VAPIDの公開鍵はBase64URL形式なのでUint8Arrayに変換する
function urlBase64ToUint8Array(base64String) {
    const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
    const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
    const rawData = atob(base64);
    return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}

// 購読処理
async function subscribe() {
    // ブラウザのサポート確認
    if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
        alert('このブラウザはWebプッシュ通知に対応していません');
        return;
    }

    // 通知許可をユーザーに求める
    const permission = await Notification.requestPermission();
    if (permission !== 'granted') {
        alert('通知が許可されませんでした');
        return;
    }

    // Service Workerを登録
    await navigator.serviceWorker.register('/sw.js');
    const registration = await navigator.serviceWorker.ready;

    // 対応する暗号化方式を取得(最近はaes128gcmが主流)
    const contentEncoding = (PushManager.supportedContentEncodings || ['aesgcm'])[0];

    // ブラウザ経由でプッシュサービスに購読を申し込む
    const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(
            document.querySelector('meta[name="vapid-public-key"]').content
        ),
    });

    // 購読情報をサーバーに送信して保存
    const { endpoint, keys: { p256dh, auth } } = subscription.toJSON();

    await fetch('/push/subscribe', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
        },
        body: JSON.stringify({
            endpoint,
            key: p256dh,
            token: auth,
            encoding: contentEncoding,
        }),
    });

    alert('通知を許可しました');
}

// 解除処理
async function unsubscribe() {
    const registration = await navigator.serviceWorker.ready;
    const subscription = await registration.pushManager.getSubscription();

    if (!subscription) {
        return;
    }

    const endpoint = subscription.endpoint;

    await subscription.unsubscribe();

    await fetch('/push/subscribe', {
        method: 'DELETE',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
        },
        body: JSON.stringify({ endpoint }),
    });

    alert('通知を解除しました');
}

document.getElementById('subscribe-btn').addEventListener('click', subscribe);
document.getElementById('unsubscribe-btn').addEventListener('click', unsubscribe);
</script>

9. 動作確認

それでは実際に動かしてみます。

手順

  1. php artisan serve でローカルサーバーを起動
  2. ブラウザでログイン
  3. 「通知を許可する」ボタンをクリック
  4. ブラウザの許可ダイアログで「許可」を選択
  5. 別のターミナルでTinkerを起動して送信
php artisan tinker
>>> $user = App\Models\User::first();
>>> $user->notify(new App\Notifications\TestNotification);

OSの通知センターに「テスト通知」が表示されれば成功です。タブを閉じた状態でも届きます

DBに購読情報が保存されているか確認したい場合
>>> App\Models\User::first()->pushSubscriptions()->get();

endpointpublic_keyauth_tokencontent_encoding がそれぞれ入っていることを確認できます。

つまずきやすいポイント

実装中によく引っかかる点を、原因と一緒にまとめます。

通知許可ダイアログが出ない

ほぼ100%、以下のどれかが原因です。

  • HTTPSでないhttp://localhost 以外)はブロックされます
  • ユーザー操作起点ではない:ページロード直後に呼んでいないか確認
  • 過去に「ブロック」したまま:ブラウザの設定で許可状態をリセット
  • シークレットウィンドウ:一部のブラウザではプッシュ通知が無効

iOSで届かない

iOSのSafariは、ホーム画面に追加してPWAとして起動した場合のみプッシュ通知が動きます。通常のSafariタブからは動きません。これはAppleの仕様です。

加えて、前述の VAPID_SUBJECT.env に無いと配送に失敗します。

通知は届くが本文が「通知」と表示される

サーバーから送ったJSONのキーと、Service Worker内の data.titledata.body の参照が一致していない可能性が高いです。event.data.json() の中身を console.log してみてください。

期限切れの購読が溜まらないか心配

このパッケージは、配送に失敗して「期限切れ」が返ってきた購読を自動でDBから削除してくれます。手動でクリーンアップを書く必要はありません(web-push-phpMessageSentReport::isSubscriptionExpired() 判定を使っています)。

本番環境への移行チェックリスト

ローカルで動作確認ができたら、本番へ移行する前に以下の点を確認してください。

VAPIDキーの管理

VAPIDキーは .env に保存していますが、本番環境では絶対にGitリポジトリにコミットしないことが大前提です。

本番サーバーへの設定方法は環境によって異なります。

環境 設定方法
Laravel Cloud ダッシュボードの「Environment Variables」に追加
EC2 / VPS サーバーの .env に直接記述、またはSSMパラメータストアなどの秘密情報管理サービスを使用
Heroku / Render 管理画面の「Config Vars」に追加

HTTPS の確認

本番環境ではHTTPS必須です。http:// のドメインでは、ブラウザがService Workerの登録もPush APIの購読も拒否します。

SSL証明書(Let's Encrypt など)の設定が完了していることを確認してから通知機能をリリースしてください。

また、本番URLを元に .envAPP_URLhttps:// で始まっているかも確認してください。

APP_URL=https://your-domain.com

APP_URLhttp:// のままだと、Laravel側で生成するURLが誤ったスキームになる場合があります。

通知送信をキュー化する

大量のユーザーに通知を送る場合、リクエストの中でプッシュサービスへのHTTPリクエストを同期的に投げるとレスポンスが遅くなります。ShouldQueue を実装してキュー経由で送信しましょう。

use Illuminate\Contracts\Queue\ShouldQueue;

class TestNotification extends Notification implements ShouldQueue
{
    // ...
}

キュードライバーはRedisやAmazon SQSなど、本番用のものを使ってください。sync ドライバーはキューとして機能しません。

QUEUE_CONNECTION=redis

Service Worker のキャッシュ制御

public/sw.js は、デプロイ後に古いファイルがブラウザにキャッシュされたままになると、新しいプッシュイベントの処理が反映されません。

Nginxや本番サーバーの設定で、sw.js に対して Cache-Control: no-store を付与することを推奨します。

location = /sw.js {
    add_header Cache-Control "no-store, no-cache, must-revalidate";
}

Laravel の場合は、ミドルウェアで制御することもできます。

// routes/web.php
Route::get('/sw.js', function () {
    return response()->file(public_path('sw.js'))
        ->withHeaders(['Cache-Control' => 'no-store']);
});

VAPID_SUBJECT の設定

iOS/Safari はもちろん、本番環境ではすべてのブラウザに対して VAPID_SUBJECT を設定しておくことを強く推奨します。

VAPID_SUBJECT=mailto:admin@your-domain.com

運用チームが実際に確認できるメールアドレスを指定してください。

購読情報の監視

このパッケージは配送失敗時に期限切れの購読を自動削除しますが、本番では配送失敗率を定期的に確認することをおすすめします。

ほとんどのユーザーで失敗が続く場合は、VAPIDキーの設定ミスやプッシュサービスへの疎通問題を疑ってください。

おまけ:Declarative Web Push

新しい標準として「Declarative Web Push」が提案されています。Service Workerを起動せずにブラウザ自身が通知を表示する方式で、バッテリー消費とプライバシーの面で有利です。

このパッケージにも DeclarativeWebPushMessage というクラスが用意されています。

use NotificationChannels\WebPush\DeclarativeWebPushMessage;

public function toWebPush(object $notifiable, $notification): DeclarativeWebPushMessage
{
    return (new DeclarativeWebPushMessage)
        ->title($this->title)
        ->icon('/icon-192.png')
        ->body($this->body)
        ->action('開く', 'open', 'https://example.com/dashboard')
        ->navigate('https://example.com/dashboard');
}

navigate() で「通知タップ時の遷移先URL」を直接指定できるので、notificationclick ハンドラ自体が不要になります。

ただし、2026年5月時点でブラウザサポートはまだ限定的です。本番では従来の WebPushMessage を使い、これは「将来の選択肢」として頭に入れておく程度で問題ありません。

まとめ

Laravelの標準通知システムにWebプッシュチャンネルを足して、ブラウザを閉じていても届く通知が実装できました。Firebaseの導入も外部サービスへの月額費用も発生しません。

実装の流れを振り返ると、次のとおりです。

  1. パッケージのインストールとVAPIDキー生成
  2. UserモデルへのHasPushSubscriptionsトレイト追加
  3. 購読情報を保存するエンドポイントの作成
  4. Notificationクラスの実装
  5. Service Workerと購読ボタンのフロント実装

次のステップ

実運用に向けては、以下を検討するとさらに使いやすくなります。

  • キュー化Notification クラスに ShouldQueue を実装して非同期送信
  • 複数デバイス対応:1ユーザーが複数ブラウザ・複数端末で購読することを想定
  • 通知履歴のDB保存database チャンネルと併用して通知履歴を残す
  • 通知設定画面:通知の種類ごとにオン/オフを切り替えられるUI

「リマインダー通知」「リアルタイム更新通知」「在庫アラート」など、外部サービスを使わずに実装できる範囲はかなり広いです。ぜひ自分のアプリに組み込んでみてください。

参考リンク

Discussion