Laravel Notificationの通知履歴機能を作りたい

方針
通知履歴をデータベースに保存したい
通知機能を実装したが、通知履歴機能を実装するために以下の課題が発生
-
notify()
メソッドの返り値がnullなのでnotify()
から先の処理で通知履歴機能を実装することはできなさそう - 通知履歴機能を実装するためには対象の通知クラス(app/Notifications)内でなんとかする必要ありそう
なので、通知クラスのデータベース通知機能を利用して通知履歴機能を実装することにする。
参考に、データベース通知の方法と、データベース通知のデフォルトをカスタマイズする方法を調べた。
参考

データベース通知ではLaravelの用意したコマンドを実行することでnotificationsマイグレーションが生成され、notificationsテーブルにデータベース通知を行うことができる。
今回はこの機能を利用して通知履歴機能を実装するので
- notificationsを任意のテーブル名に変更(notification_histories)
- notification_historiesに紐づくモデルを作成
- デフォルトチャネルの
mail
database
につづくカスタムチャネルを作成 - notifiableなモデルにmorphManyを返すメソッドを追加
を行っていく

マイグレーションファイル作成
php artisan notifications:table
生成したマイグレーションファイルを任意のテーブル名とカラムに変更
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('notification_histories', function (Blueprint $table) {
// ここに必要なカラムを設定する
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notification_histories');
}
};

notification_historiesにひもづくモデルを作成
ただのモデルの作成なのでコマンドは割愛
<?php
namespace App\Models\***;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\DatabaseNotification;
class NotificationHistory extends DatabaseNotification
{
use HasFactory;
protected $table = 'notification_histories';
}

カスタムチャネルを作成
参考にしたQiitaの記事だとコマンドを実行してカスタムチャネルを作っていたが
カスタムチャネルはDatabaseChannelを継承しており、
そのDatabaseChannelは Illuminate\Notifications\Channels
をnamespaseとしていたので
それにならってこちらでは通知クラスが作成されたディレクトリ App\Notifications
に Channelsディレクトリを作成し、そこにカスタムチャネルを置くことにした。
<?php
namespace App\Notifications\Channels;
use Illuminate\Notifications\Channels\DatabaseChannel;
use Illuminate\Notifications\Notification;
class CustomDatabaseChannel extends DatabaseChannel
{
}
通知クラスとカスタムチャネルを紐づける
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return [
CustomDatabaseChannel::class,
'mail'
];
}

notifiableなモデルにメソッドを追加
notifiableをuseしたモデルに notifications()
メソッドを追加してNotificationHistoryモデルをmorphManyで紐づける感じの処理を書く
<?php
namespace App\Models\***;
use App\Models\***\NotificationHistory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;
class Hoge extends Model
{
use HasFactory;
use Notifiable;
public function notifications()
{
return $this->morphMany(NotificationHistory::class, 'notifiable')->latest();
}
}

以上、記事を参考に実装したところ
そして以下の疑問がわいたので調査した。
- notifiableをuseしたモデルに、さらに紐づけたいデータベース通知機能があったときどうするのか
-
notifications()
以外の名前でメソッドを定義できないか
後々、モデルを改修するとなったときにnotifications()メソッドでしか定義できないとなると社内的に問題になりそうなのでもう少し深く調査した。

notifications() が呼ばれるまで
通知クラス::via()
↓
DatabaseChannel::send()
↓
RoutesNotifications::routeNotificationFor()
↓
notifiableをuseしたモデル::notifications()
※
DatabaseChannelはカスタムデータベースの親クラス
RoutesNotificationsはモデルでuseしたNotifiableがさらにuseしているトレイト
RoutesNotifications::routeNotificationFor()
notifications()はRoutesNotificationsトレイトのrouteNotificationFor()で呼ばれていることがわかったので、routeNotificationFor()を見てみる
/**
* Get the notification routing information for the given driver.
*
* @param string $driver
* @param \Illuminate\Notifications\Notification|null $notification
* @return mixed
*/
public function routeNotificationFor($driver, $notification = null)
{
if (method_exists($this, $method = 'routeNotificationFor'.Str::studly($driver))) {
return $this->{$method}($notification);
}
return match ($driver) {
'database' => $this->notifications(),
'mail' => $this->email,
default => null,
};
}
このメソッドは通知クラスのvia()で定義するチャネルごとにどのような処理を行うかが書かれている。
さらに、 routeNotificationFor()
メソッドを呼び出している DatabaseChannel::send()
を見てみる
DatabaseChannel::send()
/**
* Send the given notification.
*
* @param mixed $notifiable
* @param \Illuminate\Notifications\Notification $notification
* @return \Illuminate\Database\Eloquent\Model
*/
public function send($notifiable, Notification $notification)
{
return $notifiable->routeNotificationFor('database', $notification)->create(
$this->buildPayload($notifiable, $notification)
);
}
routeNotificationFor()
の第一引数がdatabaseとなっているため、RoutesNotifications::routeNotificationFor()
で $this->notifications()
を呼び出している。
つまり、Notifiableをuseしたモデルに定義した notifications()
を呼び出している。
つまり、notifications()メソッド以外のメソッド名を定義するためには
- notifications()以外のメソッド名を定義することは可能
- カスタムチャネルにsend()メソッドを作成してDatabaseChannel::send()をオーバーライドする
- モデルに
routeNotificationFor***()
というメソッド名を定義
すればよいことがわかった

データベース通知以外でも、もしカスタムチャネルを実装することがある場合
カスタムチャネルの作成と同時にrouteNotification***()メソッドも必要になると思われる。。。

メール送信していないのに履歴に登録するのがナンセンスな気がするのと、
送信したメール本文をDatabaseChannelでは取得できないのに気付いたので
この方法では実装できないことがわかった。
- メール送信しているような処理でデータを取得できないか見てみる
- イベントの発火からobserverで処理ができないか見てみる

notificationはモデルに紐づくのでイベントの取得にはobserverクラスが必要なのではと思っていたが
あれはDB操作時に発火されるイベント専門なので
今回実装する機能についてobserverクラスは多分作らない
Laravelのイベントとリスナーの機能を使って、通知イベントをキャッチできるらしいのでそこで送信履歴登録機能を実装してみる。
今までのLaravelの機能開発でイベントとリスナーに遭遇することがなかったのでこの機能があることを現時点で初めて知った・・・
LaravelのNotificationの仕様書にもちゃんとイベントのこと書いてあるね
ちゃんと仕様書読まなきゃね

イベント&リスナーを使って履歴登録機能を実装する
通知を送るときに発火するイベント
- NotificationSender
- NotificationSending
- NotificationSent
のうち、NotificationSentを利用する。
リスナーを新規作成
php artisan make:Listener SaveNotificationHistory --event=NotificationSent
EventServiceProviderにイベントとリスナーを登録
<?php
namespace App\Providers;
use App\Listeners\SaveNotificationHistory;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Notifications\Events\NotificationSent;
use Illuminate\Support\Facades\Event;
class EventServiceProvider extends ServiceProvider
{
/**
* The event to listener mappings for the application.
*
* @var array<class-string, array<int, class-string>>
*/
protected $listen = [
// 通知イベント
NotificationSent::class => [
SaveNotificationHistory::class,
],
];
}
作成したリスナーを編集
handle()メソッド内に書かれた処理が、イベント発火時に呼び出される
引数として受け取った$eventは
$event->channel
$event->notifiable
$event->notification
でNotificationの詳細にアクセスできる。
<?php
namespace App\Listeners;
use App\Events\NotificationSent;
use App\Notifications\SendTemplateNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Events\NotificationSent as EventsNotificationSent;
use Illuminate\Queue\InteractsWithQueue;
class SaveNotificationHistory
{
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(EventsNotificationSent $event): void
{
// Notificationの種類によって通知後の処理をわけたほうがよいかも
match (get_class($event->notification)) {
SendTemplateNotification::class => $this->saveNotificationHistory($event),
};
}
protected function saveNotificationHistory(EventsNotificationSent $event)
{
// ここに保存する処理書く
}
}

NotificationSentのリスナーについて
通知したメッセージ自体を取得する場合は
$event->response->getOriginalMessage()
から取得できた
ただ、こんなに遠回りじゃないと取得できないの?と腑に落ちないのでいる
public function handle(EventsNotificationSent $event): void
{
$message = $event->response->getOriginalMessage();
// 件名
$message->getSubject();
// html形式本文
$message->getHtmlBody();
// text形式本文
$message->getTextBody();
// to
$message->getTo();
// bcc
$message->getBcc();
// cc
$message->getCc();
}

新たな課題
- notification×mailpitで意図的に失敗できるのか
- メールステータスあるいは失敗したメールを抽出できるのか

- notification×mailpitで意図的に失敗できるのか
意図的な失敗はおそらくできない
- メールステータスあるいは失敗したメールを抽出できるのか
これもできない
ただ、NotificationSentイベントの $event->response
からdebugを出力することが可能
Log::channel('mail')->info($event->response->getDebug());
以下、ログ出力例
[2024-06-19 16:57:24] local.INFO: < 220 cc2fc2140ec5 Mailpit ESMTP Service ready
> EHLO [127.0.0.1]
< 250-cc2fc2140ec5 greets [127.0.0.1]
< 250-SIZE 0
< 250 ENHANCEDSTATUSCODES
> MAIL FROM:<hoge@example.com>
< 250 2.1.0 Ok
> RCPT TO:<***********@gmail.com>
< 250 2.1.5 Ok
> DATA
< 354 Start mail input; end with <CR><LF>.<CR><LF>
> .
< 250 2.0.0 Ok: queued
この文字列を解析して成功、失敗を見ることにする

通知対象のコレクションに対して
Notificationファサードから通知するのと
Notification::send($notifiables, $notification)
コレクションを繰り返し処理で各モデルに逐次通知する
$notifiable->notify($notification)
どちらのスピードがはやいのか検証
コレクションには、通知対象が59件分入ってる
// ファサード使用時の速度計測
$time_start = microtime(true);
try {
Notification::send($collection, $notification);
} catch (Exception $e) {
Log::channel('mail')->error($e->getMessage());
}
$time_end = microtime(true);
$time = $time_end - $time_start;
var_dump($time);
// 3.0664739608765
// 繰り返し処理での速度計測
$time_start = microtime(true);
$collection->each(function ($notifiable) use ($notification) {
try{
$notifiable->notify($notification);
} catch (Exception $e) {
Log::channel('mail')->error($e->getMessage());
}
});
$time_end = microtime(true);
$time = $time_end - $time_start;
var_dump($time);
// 2.4966559410095
結果、何回かやってみたけどnotify繰り返したほうがはやそう・・・?