In an application I'm developing, users submit their observations of animals, plants or fungi, usually accompanied by photos, that either they or curators of certain groups identify. If all is well, curators approve the users' observations and users get notifications of the approval, via email as well as inside the app. Laravel, PHP framework used to build the application, makes it super easy to send notifications to the users via different channels. Everything was working great, but there was one problem.
Since curators can approve many observations at the same time, and many could have been submitted by the same user, that user can receive a lot of emails in a short period. This is not a good experience and in the worst case could even lead to the email address used by the app being marked as the origin of spam.
The solution I came up with to tackle this issue was to periodically send a summary of unread notifications in a single email. I was excited by this solution so I decided to share it in the hope that someone might find it useful.
Notification Channels
This isn't a standard way of sending notifications and it can't go through channels that Laravel provides out of the box. I needed a custom channel for notifications and Laravel makes it easy to make and use them. Here's what the notification channel class looks like:
namespace App\Notifications\Channels;
use App\PendingNotification;
use Illuminate\Notifications\Notification;
class UnreadSummaryMailChannel
{
/**
* Send the given notification.
*
* @param mixed $notifiable
* @param \Illuminate\Notifications\Notification $notification
* @return \App\PendingNotification
*/
public function send($notifiable, Notification $notification)
{
return PendingNotification::create([
'notification_id' => $notification->id,
'notifiable_type' => $notifiable->getMorphClass(),
'notifiable_id' => $notifiable->id,
'notification' => serialize($notification),
]);
}
}
What it does is create a new record of notification and saves it to the database.
We need to track the notification ID, the notifiable (in this app theApp\User
model is the only notifiable),
and serialized notification so we can later extract the data provided to the notification object.
Model and migration
Here's the migration for pending_notifications
table:
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreatePendingNotificationsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('pending_notifications', function (Blueprint $table) {
$table->bigIncrements('id');
$table->uuid('notification_id');
$table->morphs('notifiable');
$table->text('notification');
$table->dateTime('sent_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('pending_notifications');
}
}
Besides the mentioned fields that we pass when creating the model, there is also
sent_at
that is used to keep track of notifications that have already been sent.
We could just delete the records from the database after the notification summary
is send, but I thought that it would be better to keep them a while, just in case,
and remove them after a certain time has passed.
Here's the model at this point, we'll come back to it again later.
namespace App;
use Illuminate\Database\Eloquent\Model;
class PendingNotification extends Model
{
protected $fillable = [
'notification_id', 'notifiable_id', 'notifiable_type', 'notification',
'sent_at',
];
protected $casts = [
'sent_at' => 'datetime',
];
/**
* Scope the query to get unread notifications.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return void
*/
public function scopeUnread($query)
{
$query->where(function ($query) {
$query->whereNotIn('notification_id', function ($query) {
$query->select('id')->from('notifications')->whereNotNull('read_at');
})->whereNull('sent_at');
});
}
}
To later query for unread notifications, I added a scope method. In this case,
unread notifications that should be sent via mail, in summary, haven't been marked
as read in the application and haven't already been sent. Now that I think of it,
this query scope might have been written differently to use not exists
instead of
not in
and I might try it later to see if there are any differences in performance.
The easiest way for me to get notifications that need to be sent grouped by user
that should receive them was to create a relation on App\User
model:
/**
* Unread notifications pending for summary.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function unreadNotificationsForSummaryMail()
{
return $this->morphMany(PendingNotification::class, 'notifiable')->latest()->unread();
}
And to send those notifications I added static method on App\PendingNotification
model.
...
/**
* Send out email with unread notifications summary.
*
* @return void
*/
public static function sendOut()
{
User::has('unreadNotificationsForSummaryMail')
->with('unreadNotificationsForSummaryMail')
->each(function ($user) {
$notifications = $user->unreadNotificationsForSummaryMail->map->unserialize();
$user->notify(new UnreadNotificationsSummary($notifications));
static::whereIn('id', $user->unreadNotificationsForSummaryMail->pluck('id'))->update(['sent_at' => now()]);
}, 300);
}
/**
* Unserialize original notification.
*
* @return \Illuminate\Notifications\Notification
*/
public function unserialize()
{
return unserialize($this->notification);
}
...
This way we only get users that need to be notified with the notifications that need to be sent in summary. A summary email is sent as a notification via email channel to each of those users. The query is chunked for performance. We don't need the PendingNotification models, we need the actual notification objects and we get them by unserializing what we stored in the database.
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Support\Collection;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
class UnreadNotificationsSummary extends Notification implements ShouldQueue
{
use Queueable;
private $unreadNotifications;
/**
* Create a new notification instance.
*
* @param \Illuminate\Support\Collection $unreadNotifications
* @return void
*/
public function __construct(Collection $unreadNotifications)
{
$this->unreadNotifications = $unreadNotifications;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail($notifiable)
{
return (new MailMessage)
->subject(__("Here's what you missed!"))
->markdown('emails.unreadNotificationsSummary', [
'unreadNotifications' => $this->unreadNotifications->map->toUnreadSummaryMail($notifiable),
]);
}
}
The summary notification uses markdown extended with Blade to compose the email.
@component('mail::message')
# {{ __('Notification Summary') }}
{{ __("Here's what you missed:") }}
@component('mail::table')
| {{ __('Notifications') }} | |
| ------------------------- | - |
@foreach ($unreadNotifications as $notification)
| {{ $notification->message }} | [{{ $notification->actionText }}]({{ $notification->actionUrl }}) |
@endforeach
@endcomponent
@component('mail::button', ['url' => route('login'), 'color' => 'primary'])
{{ __('See all notifications') }}
@endcomponent
{{ __('Regards from Biologer team') }}
@endcomponent
It is expected that all notifications that are sent via this custom channel
implement method toUnreadSummaryMail
that returns an object with the message,
action text, and action URL. Every notification has its link and I wanted to
give the users the ability to check them out directly from the email, just like
they could before when the notification was sent in a separate email.
We could standardize the output of toUnreadSummaryMail
method by returning a
data transfer object, but I decided that there is no need for that yet.
Here's an example of a notification that is sent this way:
namespace App\Notifications;
use App\User;
use App\FieldObservation;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
class FieldObservationApproved extends Notification implements ShouldQueue
{
use Queueable;
/**
* @var \App\User
*/
public $curator;
/**
* @var \App\FieldObservation
*/
public $fieldObservation;
/**
* Create a new notification instance.
*
* @param \App\FieldObservation $fieldObservation
* @param \App\User $curator
* @return void
*/
public function __construct(FieldObservation $fieldObservation, User $curator)
{
$this->curator = $curator;
$this->fieldObservation = $fieldObservation;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return [
'database',
Channels\UnreadSummaryMailChannel::class,
];
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [
'field_observation_id' => $this->fieldObservation->id,
'curator_name' => $this->curator->full_name,
];
}
/**
* Format data for summary mail.
*
* @param mixed $notifiable
* @return array
*/
public function toUnreadSummaryMail($notifiable)
{
return (object) [
'message' => __('One of your field observations has been approved'),
'actionText' => __('View Observation'),
'actionUrl' => route('contributor.field-observations.show', $this->fieldObservation),
];
}
}
Scheduled Command
As I want to send these notification summary emails periodically, say every day, in the console kernel I've scheduled a command to be executed. Since this is a simple command I didn't need to create a separate class for it.
// app/Console/Kernel.php
use App\PendingNotification;
...
/**
* Define the application's command schedule.
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
// ...
$schedule->call(function () {
PendingNotification::sendOut();
})->dailyAt('09:00');
}
That was my solution to this problem. I always struggle with naming thing, so that is something that can be improved here as well. Also, the code could probably be organized differently but for now, I think it's fine the way it is.
If you have some remarks or questions (about this article or anything programming-related) you can find me on twitter @zivanovicnd, I'd be very glad to hear from you as I'd like to continue writing and improve.