php elephant sticker
Photo by RealToughCandy.com on Pexels.com
目次

【実務完全ガイド】Laravelで作る通知センター――Database Notifications、既読管理、メール連携、リアルタイム通知、アクセシブルな通知UI

この記事で学べること(要点)

  • Laravel Notifications を使ったアプリ内通知の基本設計
  • Database Notifications による通知保存、一覧表示、既読・未読管理
  • メール通知、アプリ内通知、リアルタイム通知をどう使い分けるか
  • 通知の種類、重要度、通知設定、配信停止、集約の設計
  • 通知センター画面、バッジ、トースト、空状態のアクセシブルなUI
  • キュー、再試行、冪等性、監査ログ、テストの実務パターン

想定読者

  • Laravel 初〜中級エンジニア:メール通知だけでなく、アプリ内通知や既読管理も作りたい方
  • SaaS / 管理画面 / 業務システム開発者:ユーザーごとの通知センターを実装したい方
  • テックリード:通知の種類が増えてきて、設計や運用を整理したい方
  • デザイナー / QA / アクセシビリティ担当:通知バッジ、トースト、通知一覧を分かりやすく整えたい方

アクセシビリティレベル:★★★★★
通知は、利用者の注意を引くUIです。だからこそ、過剰な表示や自動更新で操作を妨げないこと、通知件数や状態を色だけで伝えないこと、role="status"aria-live を適切に使うことが重要です。本記事では、通知を「見える人だけに届く情報」ではなく、「すべての利用者が理解できる状態変化」として設計します。


1. はじめに:通知は「知らせる」だけでなく「行動につなげる」ための機能です

Webアプリケーションでは、さまざまな場面で通知が必要になります。コメントが届いた、申請が承認された、請求書が発行された、エクスポートが完了した、セキュリティ設定が変更された、期限が近づいている。こうした情報を利用者に届けることで、次に取るべき行動が分かりやすくなります。

ただし、通知は増やしすぎると逆効果です。何でも通知すると、利用者は通知を見なくなります。重要な通知と軽い通知が同じ見え方だと、本当に必要な情報が埋もれます。さらに、トーストがすぐ消える、バッジの赤色だけで状態を示す、読み上げでは通知内容が分からない、といったUIは、アクセシビリティ上も課題になります。

Laravelには Notifications という強力な仕組みがあります。メール、データベース、Slack、Broadcastなど複数チャネルに対応し、通知を整理しやすい構造を作れます。本記事では、Laravelで通知センターを作るための基本から、既読管理、メール連携、リアルタイム通知、アクセシブルなUIまで、実務で使える形にまとめます。


2. 通知の種類を整理する:すべてを同じ通知にしない

通知センターを作る前に、まず通知の種類を整理します。通知は、重要度や目的によって扱いを変える必要があります。

2.1 情報通知

例:

  • 新しいコメントが届いた
  • レポート生成が完了した
  • プロフィールが更新された

比較的軽い通知です。アプリ内通知だけで十分なことも多いです。

2.2 行動が必要な通知

例:

  • 申請の承認が必要
  • 支払い方法の更新が必要
  • 招待への回答が必要

利用者に操作を求める通知です。通知一覧から詳細画面や操作画面へ移動できる導線が必要です。

2.3 重要・セキュリティ通知

例:

  • パスワードが変更された
  • 2段階認証が無効化された
  • 不審なログインが検知された

メール通知も併用した方がよい通知です。アプリ内通知だけでは見逃される可能性があります。

2.4 定期・集約通知

例:

  • 週次レポート
  • 未対応タスクのまとめ
  • コメントのダイジェスト

通知が多くなりすぎる場合、個別通知ではなく集約した方が親切です。

通知を設計するときは、「これはすぐ知らせるべきか」「メールも必要か」「一覧で見られればよいか」「行動が必要か」を決めておくと、通知疲れを防ぎやすくなります。


3. Laravel Notifications の基本

Laravelでは、通知クラスを作って、ユーザーなどの Notifiable モデルへ送信します。多くの場合、User モデルには Notifiable trait が含まれています。

通知クラスを作成します。

php artisan make:notification ReportGeneratedNotification
namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;

class ReportGeneratedNotification extends Notification
{
    use Queueable;

    public function __construct(
        public int $reportId,
        public string $title
    ) {}

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

    public function toArray(object $notifiable): array
    {
        return [
            'type' => 'report.generated',
            'report_id' => $this->reportId,
            'title' => $this->title,
            'message' => 'レポートの生成が完了しました。',
            'url' => route('reports.show', $this->reportId),
        ];
    }
}

送信側:

$user->notify(new ReportGeneratedNotification(
    reportId: $report->id,
    title: $report->title
));

この例では、通知を database チャネルに保存します。つまり、通知センターの一覧に表示できます。


4. Database Notifications の準備

アプリ内通知を保存するには、notifications テーブルを作成します。

php artisan notifications:table
php artisan migrate

これにより、notifications テーブルが作られます。通知データはJSONとして保存され、既読状態は read_at で管理されます。

ユーザーの通知一覧は、次のように取得できます。

$notifications = auth()->user()
    ->notifications()
    ->latest()
    ->paginate(20);

未読通知だけなら次のように取得します。

$unreadNotifications = auth()->user()
    ->unreadNotifications()
    ->latest()
    ->get();

既読にする場合:

$notification = auth()->user()
    ->notifications()
    ->whereKey($id)
    ->firstOrFail();

$notification->markAsRead();

すべて既読にする場合:

auth()->user()
    ->unreadNotifications
    ->markAsRead();

Database Notifications は、通知センターの基本として非常に扱いやすいです。


5. 通知データの設計:あとから表示しやすい形にする

通知の data には自由に情報を入れられます。ただし、何でも入れると表示側が混乱します。実務では、次のような構造を標準にすると扱いやすいです。

[
    'type' => 'report.generated',
    'title' => 'レポート生成完了',
    'message' => '4月の売上レポートを確認できます。',
    'url' => route('reports.show', $report->id),
    'severity' => 'info',
    'action_label' => 'レポートを確認する',
]

おすすめの項目は次の通りです。

  • type:通知の種類
  • title:短い見出し
  • message:本文
  • url:詳細や操作へのリンク
  • severity:info / success / warning / danger など
  • action_label:リンクやボタンの文言
  • meta:必要に応じた補足情報

このようにしておくと、通知一覧、トースト、メール通知などで再利用しやすくなります。

注意点として、通知データに機密情報を入れすぎないことが大切です。通知テーブルは後から表示される可能性があり、ログや管理画面でも扱われます。本文に個人情報や秘密情報を含める場合は、必要最小限にしましょう。


6. 通知センター画面:一覧、既読状態、空状態を整える

通知センターは、ただ通知を並べるだけではなく、状態が分かるように設計します。

<section aria-labelledby="notifications-title">
    <h1 id="notifications-title" class="text-2xl font-semibold">
        通知
    </h1>

    <p role="status" aria-live="polite" class="mt-2 text-sm">
        未読通知が {{ auth()->user()->unreadNotifications()->count() }} 件あります。
    </p>

    @if($notifications->isEmpty())
        <div class="border rounded p-4 mt-6">
            <h2 class="text-lg font-semibold">通知はありません</h2>
            <p class="mt-2">
                新しいお知らせが届くと、この画面に表示されます。
            </p>
        </div>
    @else
        <ul class="mt-6 divide-y">
            @foreach($notifications as $notification)
                @php
                    $data = $notification->data;
                    $isUnread = is_null($notification->read_at);
                @endphp

                <li class="py-4">
                    <article aria-labelledby="notification-{{ $notification->id }}-title">
                        <div class="flex items-start justify-between gap-4">
                            <div>
                                <h2 id="notification-{{ $notification->id }}-title" class="font-semibold">
                                    {{ $data['title'] ?? '通知' }}

                                    @if($isUnread)
                                        <span class="ml-2 rounded bg-blue-100 px-2 py-1 text-sm text-blue-900">
                                            未読
                                        </span>
                                    @endif
                                </h2>

                                <p class="mt-1">
                                    {{ $data['message'] ?? '' }}
                                </p>

                                <p class="mt-1 text-sm text-gray-600">
                                    {{ $notification->created_at->diffForHumans() }}
                                </p>
                            </div>

                            @if(!empty($data['url']))
                                <a href="{{ $data['url'] }}" class="underline">
                                    {{ $data['action_label'] ?? '詳細を見る' }}
                                </a>
                            @endif
                        </div>
                    </article>
                </li>
            @endforeach
        </ul>

        <div class="mt-6">
            {{ $notifications->links() }}
        </div>
    @endif
</section>

この例では、未読状態を青色だけでなく「未読」という文字で表示しています。色だけに頼らないことが大切です。
また、空状態でも「通知はありません」だけで終わらせず、今後どこに表示されるかを説明しています。


7. 既読管理:自動既読と手動既読を分けて考える

通知の既読管理には、大きく2つの考え方があります。

7.1 表示したら既読にする

通知詳細を開いたり、通知リンクをクリックしたタイミングで既読にします。
これは自然ですが、一覧を表示しただけで既読にするのは少し注意が必要です。利用者が本当に読んだとは限らないためです。

7.2 手動で既読にする

「既読にする」ボタンや「すべて既読にする」操作を用意します。
利用者が自分で状態を管理しやすい反面、操作が少し増えます。

実務では、通知リンクを開いたときに既読化し、必要に応じて「すべて既読」も用意するのが扱いやすいです。

Route::patch('/notifications/{id}/read', function (string $id) {
    $notification = auth()->user()
        ->notifications()
        ->whereKey($id)
        ->firstOrFail();

    $notification->markAsRead();

    return back()->with('status', '通知を既読にしました。');
})->name('notifications.read');

UI側:

<form method="POST" action="{{ route('notifications.read', $notification->id) }}">
    @csrf
    @method('PATCH')

    <button type="submit" class="underline">
        既読にする
    </button>
</form>

既読化後は role="status" で結果を知らせると親切です。


8. 通知バッジ:数字とラベルで状態を伝える

ヘッダーやサイドバーに通知バッジを表示する場合、赤い丸だけでは意味が伝わりません。数字とテキストを含めます。

<a href="{{ route('notifications.index') }}" class="relative inline-flex items-center">
    <span>通知</span>

    @if($unreadCount > 0)
        <span class="ml-2 rounded-full bg-red-600 px-2 py-1 text-sm text-white">
            {{ $unreadCount }}
        </span>

        <span class="sr-only">
            未読通知が {{ $unreadCount }} 件あります
        </span>
    @endif
</a>

見える人には数字で、スクリーンリーダー利用者には sr-only の説明で伝えます。
「赤い丸があるから分かる」ではなく、「未読通知が何件あるか」をテキストで伝えることが重要です。


9. トースト通知:使いすぎず、消え方に注意します

保存完了や軽い通知には、トーストUIを使うことがあります。便利ですが、アクセシビリティ上の注意点があります。

  • すぐ消えすぎない
  • マウスだけでなくキーボードでも閉じられる
  • 重要な通知をトーストだけにしない
  • 自動でフォーカスを奪わない
  • role="status" または role="alert" を適切に使う

成功通知の例:

@if(session('status'))
    <div role="status" aria-live="polite" class="border rounded p-3 mb-4">
        {{ session('status') }}
    </div>
@endif

重大なエラーの場合:

@if(session('error'))
    <div role="alert" class="border rounded p-3 mb-4">
        {{ session('error') }}
    </div>
@endif

基本的には、重要度の低い完了通知は role="status"、緊急性の高い失敗は role="alert" と考えると分かりやすいです。
ただし、role="alert" は読み上げを強く割り込ませることがあるため、乱用しないようにします。


10. メール通知との使い分け:重要度と行動必要性で判断します

同じ通知でも、アプリ内通知だけでよいものと、メールでも届けるべきものがあります。

アプリ内通知だけでよい例

  • コメントが追加された
  • レポート生成が完了した
  • 軽微な設定変更が完了した

メールも送るべき例

  • パスワードが変更された
  • 請求書が発行された
  • セキュリティ設定が変更された
  • 招待が送られた
  • 重要な承認依頼がある

Laravel Notifications では、via() でチャネルを切り替えられます。

public function via(object $notifiable): array
{
    return ['database', 'mail'];
}

メール本文でも、通知センターと同じように、具体的な件名、短い本文、明確なリンク文言を使います。
たとえば「こちら」ではなく「請求書を確認する」のように、行動が分かる文言にします。


11. 通知設定:利用者に選べる余地を用意します

通知が増えてくると、利用者ごとに受け取り方を選べる設計が必要になります。特にメール通知は、不要な通知が多いと配信停止や不満につながります。

通知設定の例:

  • アプリ内通知:常に受け取る
  • メール通知:重要なものだけ
  • ダイジェスト通知:毎日または毎週
  • セキュリティ通知:停止不可
  • マーケティング通知:任意で解除可能

DB設計の例:

notification_preferences
- user_id
- type
- channel
- enabled

通知送信前に設定を確認します。

if ($user->wantsNotification('report.generated', 'mail')) {
    $user->notify(new ReportGeneratedNotification($report->id, $report->title));
}

すべてをユーザーに選ばせる必要はありませんが、通知が増えるほど、設定画面は重要になります。


12. 通知の集約:通知疲れを防ぎます

同じ種類の通知が短時間に何件も届くと、利用者は疲れてしまいます。
たとえば、コメントが10件追加された場合に10通の通知を送るより、「新しいコメントが10件あります」とまとめた方が親切なことがあります。

集約の考え方:

  • 短時間の同種通知をまとめる
  • ダイジェストとして定期通知する
  • 未読件数だけをバッジで示す
  • 一覧では詳細を確認できるようにする

実務では、最初から複雑な集約を作る必要はありません。通知が増え始めたら、ログや利用状況を見ながら、よく発生する通知から集約していくと安全です。


13. キュー化:通知送信は非同期を基本にします

メールや外部通知は、同期で送ると画面のレスポンスが遅くなります。通知クラスに Queueable を使い、必要に応じて ShouldQueue を実装します。

use Illuminate\Contracts\Queue\ShouldQueue;

class ReportGeneratedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    // ...
}

これにより、通知送信をキューで処理できます。
運用では、通知キューの遅延や失敗も監視しましょう。通知が遅れていると、ユーザーからは「処理が終わっていない」ように見える場合があります。


14. 冪等性:同じ通知を二重送信しない設計

キューやイベントを使うと、同じ通知が複数回送られる可能性があります。重要通知では、二重送信防止を考えます。

方法の例:

  • 通知対象ごとに送信済みフラグを持つ
  • 同じ type と対象IDの未読通知があれば作らない
  • キャッシュロックで短時間の重複を防ぐ

例:

$exists = $user->notifications()
    ->where('type', ReportGeneratedNotification::class)
    ->where('data->report_id', $report->id)
    ->exists();

if (! $exists) {
    $user->notify(new ReportGeneratedNotification($report->id, $report->title));
}

通知が少ないうちは不要でも、請求やセキュリティ通知では特に重要です。


15. リアルタイム通知:便利ですが、操作を邪魔しない設計にします

Broadcastingを使うと、通知をリアルタイムに画面へ届けられます。
ただし、リアルタイム通知は注意が必要です。画面が勝手に更新されると、利用者の操作を妨げる可能性があります。

アクセシブルなリアルタイム通知の基本:

  • フォーカスを勝手に動かさない
  • 通知件数をテキストで更新する
  • aria-live="polite" を使う
  • 緊急通知だけ role="alert" を検討する
  • トーストだけで重要情報を完結させない

例:

<div id="notification-live-region" role="status" aria-live="polite" class="sr-only"></div>

リアルタイム通知を受け取ったら、この領域に短い文言を入れます。

新しい通知が1件あります。

これにより、視覚的なバッジ更新だけでなく、読み上げでも状態変化を伝えられます。


16. 通知センターのテスト:保存、既読、表示を守ります

通知機能は状態が多いため、テストで守る価値があります。

16.1 通知が保存されるテスト

public function test_report_generated_notification_is_stored()
{
    $user = User::factory()->create();

    $user->notify(new ReportGeneratedNotification(
        reportId: 1,
        title: '月次レポート'
    ));

    $this->assertDatabaseHas('notifications', [
        'notifiable_id' => $user->id,
        'notifiable_type' => User::class,
    ]);
}

16.2 既読化のテスト

public function test_user_can_mark_notification_as_read()
{
    $user = User::factory()->create();

    $user->notify(new ReportGeneratedNotification(
        reportId: 1,
        title: '月次レポート'
    ));

    $notification = $user->notifications()->first();

    $this->actingAs($user)
        ->patch(route('notifications.read', $notification->id))
        ->assertRedirect();

    $this->assertNotNull($notification->fresh()->read_at);
}

16.3 他人の通知を既読にできないテスト

public function test_user_cannot_mark_others_notification_as_read()
{
    $user = User::factory()->create();
    $other = User::factory()->create();

    $other->notify(new ReportGeneratedNotification(
        reportId: 1,
        title: '月次レポート'
    ));

    $notification = $other->notifications()->first();

    $this->actingAs($user)
        ->patch(route('notifications.read', $notification->id))
        ->assertNotFound();
}

通知は個人に紐づく情報なので、他人の通知を操作できないことを必ず確認します。


17. よくある落とし穴と回避策

17.1 通知が多すぎる

すべてを通知すると、重要な通知が埋もれます。
通知の種類と重要度を整理し、必要なら集約します。

17.2 未読バッジが色だけ

赤い丸だけでは意味が伝わりません。
数字とテキストで未読件数を示します。

17.3 トーストがすぐ消える

読み終わる前に消える通知は不親切です。
重要情報は通知センターにも残します。

17.4 メール通知とアプリ内通知の内容が違いすぎる

利用者が混乱します。
通知データの基本項目を共通化します。

17.5 他人の通知を操作できる

認可漏れです。
通知取得時は必ずログインユーザーの通知から検索します。

17.6 リアルタイム通知が操作を邪魔する

フォーカスを奪わず、aria-live="polite" で控えめに伝えます。


18. チェックリスト(配布用)

設計

  • [ ] 通知の種類と重要度を整理している
  • [ ] アプリ内通知、メール通知、リアルタイム通知の使い分けがある
  • [ ] 通知データの共通項目がある
  • [ ] 通知設定や配信停止の方針がある

Database Notifications

  • [ ] notifications テーブルを作成している
  • [ ] 通知一覧がある
  • [ ] 既読・未読管理がある
  • [ ] 他人の通知を操作できない

UI / アクセシビリティ

  • [ ] 通知件数をテキストで示している
  • [ ] 未読状態を色だけで示していない
  • [ ] 空状態の説明がある
  • [ ] 完了通知は role="status"
  • [ ] 重要な失敗は role="alert"
  • [ ] リアルタイム更新でフォーカスを奪わない

運用

  • [ ] 通知送信をキュー化している
  • [ ] 重要通知の二重送信を防いでいる
  • [ ] 通知が増えたときの集約方針がある
  • [ ] 通知失敗を監視している

テスト

  • [ ] 通知保存のテストがある
  • [ ] 既読化のテストがある
  • [ ] 他人の通知を操作できないテストがある
  • [ ] 通知一覧の表示テストがある

19. まとめ

Laravelの通知機能は、メールを送るだけでなく、アプリ内通知、通知センター、既読管理、リアルタイム更新まで広げられる強力な仕組みです。大切なのは、通知を増やすことではなく、必要な情報を、必要なタイミングで、分かりやすく届けることです。

まずは Database Notifications で通知を保存し、通知一覧、未読バッジ、既読化を整えましょう。次に、重要通知はメールと組み合わせ、通知設定や集約を検討します。リアルタイム通知は便利ですが、操作を邪魔しないように控えめに使います。

通知は、利用者の行動を助けるための道案内です。色だけに頼らず、テキストで状態を伝え、読み上げにも配慮し、必要な通知が埋もれないように設計することで、信頼される通知体験を作れます。


参考リンク

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)