はじめに
この記事では、Laravel 12の公式WebSocketサーバー「Laravel Reverb」を使って、
お問い合わせフォームから送信があった際に、
管理画面へリアルタイムで通知を表示する機能を実装します。
記事作成の背景として、
12月入社する会社で使う技術を事前にインプットする目的で記事作成をしました。
この記事で作るもの
[ユーザー] → お問い合わせ送信
↓
[Laravel] → データベース保存 → イベント発火
↓
[Laravel Reverb] → WebSocket経由でブロードキャスト
↓
[管理画面] → リアルタイムで通知表示 + リストに追加
完成イメージ
- ユーザーがお問い合わせフォームを送信
- 管理者がダッシュボードを開いていると、画面をリロードせずに新着通知が表示される
- お問い合わせ一覧にも新しいデータがリアルタイムで追加される
Laravel Reverbとは?
Laravel Reverbは、Laravel 11で導入された公式のWebSocketサーバーです。
従来はPusherやSoketi、Laravel Websocketsなどのサードパーティを使う必要がありましたが、Reverbを使えばLaravelだけで完結するリアルタイム通信が実現できます。
Reverbの特徴:
- Laravel公式サポート
- 設定がシンプル
- 外部サービス不要(完全セルフホスト)
- Laravel Echoとの連携が簡単
環境
| 項目 | バージョン |
|---|---|
| PHP | 8.2以上 |
| Laravel | 12.x |
| Laravel Reverb | 1.x |
| Node.js | 18以上 |
| Laravel Echo | 2.x |
| Pusher JS | 8.x |
1. 環境構築
コマンドについて、「php artisan〜」で記載しておりますが、
Laravel sailで環境構築した方は「(./vendor/bin/)sail artisan〜」
に置き換えてください。
1.1 Laravel Reverbのインストール
まず、Laravel Reverbをインストールします。
composer require laravel/reverb
次に、Reverbの設定ファイルを公開します。
php artisan install:broadcasting
上記コマンドを実行すると、
以下のファイルが生成・更新されます:
-
config/reverb.php- Reverbの設定ファイル -
config/broadcasting.php- ブロードキャストの設定ファイル -
routes/channels.php- チャンネル認可のルート
また、.envファイルにReverb関連の環境変数が追加されます。
1.2 フロントエンドパッケージのインストール
Laravel Echoとpusher-jsをインストールします。
npm install laravel-echo pusher-js
1.3 .env設定
.envファイルに以下の設定を追加・確認します。
ブロードキャストドライバーをReverbに設定
BROADCAST_CONNECTION=reverb
Reverb設定
REVERB_APP_ID=my-app-id
REVERB_APP_KEY=my-app-key
REVERB_APP_SECRET=my-app-secret
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=http
Vite用(フロントエンドからReverbに接続するため)
VITE_REVERB_APP_KEY="\${REVERB_APP_KEY}"
VITE_REVERB_HOST="\${REVERB_HOST}"
VITE_REVERB_PORT="\${REVERB_PORT}"
VITE_REVERB_SCHEME="\${REVERB_SCHEME}"
ポイント
REVERB_APP_KEYとREVERB_APP_SECRETは任意の文字列でOKです- 本番環境では
REVERB_SCHEME=httpsに変更してください
1.4 設定ファイルの確認
config/reverb.phpが以下のようになっていることを確認します。
<?php
return [
'default' => env('REVERB_SERVER', 'reverb'),
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
],
],
],
];
2. 実装
2.1 データベースの準備
まず、お問い合わせを保存するテーブルを作成します。
php artisan make:migration create_contacts_table`
マイグレーションファイルを編集します。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('contacts', function (Blueprint $table) {
$table->id();
$table->string('name')->comment('名前');
$table->string('email')->comment('メールアドレス');
$table->text('body')->comment('本文');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('contacts');
}
};
マイグレーションを実行します。
php artisan migrate
2.2 Modelの作成
php artisan make:model Contact
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Contact extends Model
{
protected $fillable = [
'name',
'email',
'body',
];
}
2.3 イベントクラスの作成
お問い合わせ受信時にブロードキャストするイベントを作成します。
php artisan make:event ContactReceived
生成されたファイルを以下のように編集します。
<?php
namespace App\Events;
use App\Models\Contact;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ContactReceived implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $contact;
/**
* Create a new event instance.
*/
public function __construct(Contact $contact)
{
$this->contact = $contact;
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('admin.notifications'),
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'ContactReceived';
}
}
ポイント解説:
| 項目 | 説明 |
|---|---|
ShouldBroadcast |
このインターフェースを実装することで、イベントがブロードキャストされます |
public $contact |
publicプロパティは自動的にブロードキャストデータに含まれます |
PrivateChannel |
認証が必要なプライベートチャンネルを使用します |
broadcastAs() |
ブロードキャスト時のイベント名を指定します(フロントエンドで.ContactReceivedとして受信) |
2.4 チャンネル認可の設定
プライベートチャンネルを使用するため、認可ルールを設定します。
<?php
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('admin.notifications', function ($user) {
// ログイン済みユーザーのみリッスン可能
return $user !== null;
});
補足
本番環境では、管理者ロールのチェックなど、より厳密な認可を実装してください。
例:return $user !== null && $user->is_admin;
2.5 コントローラーの作成
お問い合わせフォームを処理するコントローラーを作成します。
php artisan make:controller ContactController
<?php
namespace App\Http\Controllers;
use App\Events\ContactReceived;
use App\Models\Contact;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ContactController extends Controller
{
/**
* お問い合わせフォームを表示
*/
public function index()
{
return view('contact.index');
}
/**
* お問い合わせを送信
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email'],
'body' => ['required', 'string', 'max:2000'],
]);
DB::transaction(function () use ($validated) {
// お問い合わせを保存
$contact = Contact::create($validated);
// リアルタイム通知イベントを発火
ContactReceived::dispatch($contact);
});
return redirect()->route('contact.complete');
}
/**
* 送信完了画面を表示
*/
public function complete()
{
return view('contact.complete');
}
}
ポイント:
-
ContactReceived::dispatch($contact)でイベントを発火 - トランザクション内でイベントを発火することで、データ保存とブロードキャストの整合性を保ちます
2.6 管理画面のコントローラー
管理者用のダッシュボードコントローラーを作成します。
php artisan make:controller Admin/DashboardController
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Contact;
class DashboardController extends Controller
{
/**
* 管理ダッシュボード
*/
public function index()
{
$latestContacts = Contact::orderByDesc('created_at')
->limit(10)
->get();
return view('admin.dashboard', [
'latestContacts' => $latestContacts,
]);
}
}
2.7 ルーティングの設定
<?php
use App\Http\Controllers\ContactController;
use App\Http\Controllers\Admin\DashboardController;
use Illuminate\Support\Facades\Route;
// お問い合わせフォーム(一般公開)
Route::get('/contact', [ContactController::class, 'index'])->name('contact');
Route::post('/contact', [ContactController::class, 'store'])->name('contact.store');
Route::get('/contact/complete', [ContactController::class, 'complete'])->name('contact.complete');
// 管理画面(認証必須)
Route::prefix('admin')
->middleware('auth')
->name('admin.')
->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
});
2.8 フロントエンドの設定(Laravel Echo)
resources/js/bootstrap.jsを編集して、Laravel Echoを設定します。
import axios from 'axios';
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.axios = axios;
window.Pusher = Pusher;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
// CSRFトークンを設定
const token = document.head.querySelector('meta[name="csrf-token"]');
if (token) {
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.getAttribute('content');
}
// Laravel Echo(Reverb)の設定
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
authorizer: (channel, options) => {
return {
authorize: (socketId, callback) => {
axios.post('/broadcasting/auth', {
socket_id: socketId,
channel_name: channel.name
})
.then(response => {
callback(null, response.data);
})
.catch(error => {
callback(error);
});
}
};
}
});
ポイント解説:
| 設定項目 | 説明 |
|---|---|
broadcaster: 'reverb' |
Reverbをブロードキャスターとして使用 |
wsHost / wsPort
|
WebSocket接続先のホストとポート |
forceTLS |
HTTPSを使用するかどうか |
authorizer |
プライベートチャンネルの認可処理をカスタマイズ |
resources/js/app.jsでbootstrapを読み込みます。
import './bootstrap';
2.9 HTMLエスケープ用ユーティリティ
XSS対策のため、HTMLエスケープ関数を用意します。
こちらのHTMLエスケープ関数は簡易的に作成したので、ご参考レベルで。
// HTML エスケープ関数
export function escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
bootstrap.jsでグローバルに公開します。
import { escapeHtml } from './utils';
// ... 既存のコード ...
window.escapeHtml = escapeHtml;
2.10 管理画面のビュー
管理ダッシュボードのビューを作成します。
resources/views/admin/dashboard.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>管理ダッシュボード</title>
@vite(['resources/js/app.js'])
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100">
<div class="container mx-auto py-8 px-4">
<h1 class="text-2xl font-bold mb-6">管理ダッシュボード</h1>
<div class="grid gap-6 md:grid-cols-2">
<!-- リアルタイム通知パネル -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-semibold text-gray-800 mb-4">リアルタイム通知</h2>
<p class="text-sm text-gray-600">
新しいお問い合わせが届くとここに表示されます。
</p>
<!-- 通知バナー(初期状態は非表示) -->
<div class="mt-4" id="contact-alert-banner" hidden>
<div class="p-4 bg-blue-100 border border-blue-300 rounded text-blue-800 text-sm">
🔔 新しいお問い合わせが届きました。
<button type="button" class="ml-2 underline hover:no-underline" id="scroll-to-list-btn">
確認する
</button>
</div>
</div>
</div>
<!-- 概要パネル -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-semibold text-gray-800 mb-4">概要</h2>
<ul class="text-sm text-gray-600 space-y-2">
<li>最新お問い合わせ件数: <span id="contact-count">{{ $latestContacts->count() }}件</span></li>
<li>リアルタイム通知: <span class="font-semibold text-green-600">準備完了</span></li>
</ul>
</div>
</div>
<!-- お問い合わせ一覧 -->
<div class="mt-6 bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b">
<h2 class="text-lg font-semibold">最新のお問い合わせ</h2>
</div>
<div class="p-4">
<ul id="contact-stream" class="divide-y divide-gray-200">
@forelse ($latestContacts as $contact)
<li class="py-3" data-contact-id="{{ $contact->id }}">
<p class="text-sm font-semibold text-gray-800">
{{ $contact->name }}
<span class="ml-4 text-xs text-gray-500">
{{ $contact->created_at->format('Y/m/d H:i') }}
</span>
</p>
<p class="text-sm text-gray-600 line-clamp-2">
{{ Str::limit($contact->body, 120) }}
</p>
</li>
@empty
<li class="py-6 text-center text-sm text-gray-500" id="no-contacts-message">
お問い合わせはまだありません。
</li>
@endforelse
</ul>
</div>
</div>
</div>
<script type="module">
document.addEventListener('DOMContentLoaded', () => {
const banner = document.getElementById('contact-alert-banner');
const stream = document.getElementById('contact-stream');
const noContactsMsg = document.getElementById('no-contacts-message');
const countSpan = document.getElementById('contact-count');
// スクロールボタンの動作
document.getElementById('scroll-to-list-btn')?.addEventListener('click', () => {
stream.scrollIntoView({ behavior: 'smooth', block: 'start' });
banner.hidden = true;
});
// Laravel Echo(Reverb)でリアルタイム通知を受信
if (window.Echo) {
window.Echo.private('admin.notifications')
.listen('.ContactReceived', (e) => {
console.log('新しいお問い合わせを受信:', e);
// 通知バナーを表示
banner.hidden = false;
const contact = e.contact;
// 日時フォーマット
const date = new Date(contact.created_at);
const formattedDate = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
// 本文の抜粋(120文字)
let bodyPreview = contact.body;
if (bodyPreview.length > 120) {
bodyPreview = bodyPreview.substring(0, 120) + '...';
}
// 新しいリストアイテムを作成
const li = document.createElement('li');
li.className = 'py-3 bg-blue-50 transition-colors duration-1000';
li.dataset.contactId = contact.id;
li.innerHTML = `
<p class="text-sm font-semibold text-gray-800">
${escapeHtml(contact.name)}
<span class="ml-4 text-xs text-gray-500">${formattedDate}</span>
</p>
<p class="text-sm text-gray-600 line-clamp-2">${escapeHtml(bodyPreview)}</p>
`;
// "お問い合わせはまだありません" があれば削除
if (noContactsMsg) {
noContactsMsg.remove();
}
// リストの先頭に追加
stream.insertBefore(li, stream.firstChild);
// 件数を更新
const currentCount = parseInt(countSpan.textContent) || 0;
countSpan.textContent = `${currentCount + 1}件`;
// ハイライトをフェードアウト(3秒後)
setTimeout(() => {
li.classList.remove('bg-blue-50');
}, 3000);
});
console.log('Laravel Echo: admin.notifications チャンネルに接続しました');
} else {
console.error('Laravel Echo が初期化されていません');
}
});
</script>
</body>
</html>
ポイント解説:
| コード | 説明 |
|---|---|
window.Echo.private('admin.notifications') |
プライベートチャンネルに接続 |
.listen('.ContactReceived', ...) |
イベント名の前にドット(.)を付ける(broadcastAs()で指定した名前) |
escapeHtml() |
XSS対策のためユーザー入力をエスケープ |
bg-blue-50 |
新着アイテムを一時的にハイライト表示 |
2.11 お問い合わせフォームのビュー
resources/views/contact/index.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>お問い合わせ</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100">
<div class="container mx-auto py-12 px-4">
<div class="max-w-xl mx-auto bg-white rounded-lg shadow p-8">
<h1 class="text-2xl font-bold mb-6">お問い合わせ</h1>
@if ($errors->any())
<div class="mb-6 p-4 bg-red-50 border border-red-300 rounded">
<ul class="text-sm text-red-600">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form action="{{ route('contact.store') }}" method="POST">
@csrf
<div class="mb-4">
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
お名前 <span class="text-red-500">*</span>
</label>
<input type="text" name="name" id="name"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value="{{ old('name') }}" required>
</div>
<div class="mb-4">
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">
メールアドレス <span class="text-red-500">*</span>
</label>
<input type="email" name="email" id="email"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value="{{ old('email') }}" required>
</div>
<div class="mb-6">
<label for="body" class="block text-sm font-medium text-gray-700 mb-1">
お問い合わせ内容 <span class="text-red-500">*</span>
</label>
<textarea name="body" id="body" rows="5"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required>{{ old('body') }}</textarea>
</div>
<button type="submit"
class="w-full bg-blue-600 text-white py-3 px-6 rounded-lg hover:bg-blue-700 transition">
送信する
</button>
</form>
</div>
</div>
</body>
</html>
2.12 送信完了画面のビュー
resources/views/contact/complete.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>送信完了</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100">
<div class="container mx-auto py-12 px-4">
<div class="max-w-xl mx-auto bg-white rounded-lg shadow p-8 text-center">
<div class="text-green-500 text-6xl mb-4">✓</div>
<h1 class="text-2xl font-bold mb-4">送信完了</h1>
<p class="text-gray-600 mb-6">
お問い合わせありがとうございます。<br>
内容を確認の上、ご連絡いたします。
</p>
<a href="/" class="text-blue-600 hover:underline">トップページに戻る</a>
</div>
</div>
</body>
</html>
3. 動作確認
3.1 必要なサーバーの起動
動作確認には以下の3つのサーバーを起動する必要があります。
ターミナル1: Laravelサーバー
php artisan serve
ターミナル2: Reverbサーバー
php artisan reverb:start
ターミナル3: Vite開発サーバー
npm run dev
3.2 動作確認の手順
-
管理画面を開く
- ブラウザで
http://localhost:8000/admin/dashboardにアクセス - ログインが必要な場合は先にログインしてください
- ブラウザで
-
別のブラウザ/タブでお問い合わせフォームを開く
-
http://localhost:8000/contactにアクセス
-
-
お問い合わせを送信
- フォームに必要事項を入力して送信
-
管理画面を確認
- 画面をリロードせずに、通知バナーが表示されることを確認
- お問い合わせ一覧の先頭に新しいデータが追加されることを確認
- 新着アイテムが青色でハイライトされ、3秒後にフェードアウトすることを確認
3.3 トラブルシューティング
WebSocket接続エラーが出る場合
- Reverbサーバーが起動しているか確認
php artisan reverb:start --debug2..envの設定を確認
BROADCAST_CONNECTION=reverb
VITE_REVERB_HOST=localhost
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
-
Viteを再起動
npm run dev#### チャンネル認可エラーが出る場合 -
ログイン状態を確認
-
routes/channels.phpの認可ロジックを確認 -
CSRFトークンが正しく設定されているか確認
イベントが発火しない場合
-
ContactReceivedクラスがShouldBroadcastを実装しているか確認 - キューワーカーが必要な場合は起動
php artisan queue:work> Note
開発環境では
QUEUE_CONNECTION=syncにすることで、キューワーカーなしでイベントが即時ブロードキャストされます。
まとめ
この記事では、Laravel Reverbを使ってリアルタイム通知機能を実装しました。
実装のポイント
- Laravel Reverb - Laravel公式のWebSocketサーバーで、外部サービス不要
- ShouldBroadcast - イベントクラスにこのインターフェースを実装するだけでブロードキャスト可能
-
PrivateChannel - 認証が必要なチャンネルで、
routes/channels.phpで認可ルールを設定 - Laravel Echo - フロントエンドでWebSocket接続を簡単に扱える
発展的な使い方
- Presence Channel: オンラインユーザーの一覧表示
- Whisper: サーバーを経由しないクライアント間通信(タイピング中表示など)
- Redis連携: 複数サーバー間でのスケーリング
Laravel Reverbを使えば、チャット、通知、リアルタイムダッシュボードなど、様々なリアルタイム機能を簡単に実装できます。ぜひ試してみてください!