Upgrade your Filament notifications by live updating them

Published at Sep 10, 2024

While working on unolia.com I made a little upgrade to my notifications! I wanted my user to have feedback on the progress of the tasks. In one PHP class, no additional table, clean API.

Prerequisites

  • Laravel 11 app (with at least job batches and anonymous events)
  • Laravel Reverb installed and configured
  • Filament v3 Notifications

The problem

To better understand what we are trying to improve, here is the current Livewire function that is called when the user requests issues to be checked.

Copied!
#[Renderless]
public function checkIssues()
{
$records = $this->zone->records()->get();
 
foreach ($records as $record) {
dispatch(new CheckZoneRecord($record));
}
 
Notification::make()
->title('New issues checks have been requested')
->success()
->send();
}

This function does two things:

  1. Sending CheckZoneRecord jobs to the queues
  2. Sending a visual notification, more like a toast, telling the user that the checks have started.

Depending on the part of my application, they also receive a notification when the background task is done. But here I have a batch of jobs and there is no way that one job will know and be responsible to send this success notification.

That was the first challenge: how to tell the user that checks are done? Easy. Use job batch to send a notification when it's done.

Copied!
Bus::batch($jobs)->finally(fn() => sendSuccessNotification())

But like I said, I already had this kind of user experience in my app.

I wanted more!

I wanted a progress bar!

The solution

I wanted the solution to be as seamless and transparent as possible, but in the end, I had to slightly customize Filament.

Customize Filament

We are going to use our own view for the notifications, so go to AppServiceProvider.php and thoses lines int the boot() function:

Copied!
Notification::configureUsing(function (Notification $notification): void {
$notification->view('components.notification');
});

Original file:
vendor/filament/notifications/resources/views/notification.blade.php

Now create the component with

Copied!
php artisan make:component notification --view

Copy and paste the content of the original file and and modify the body part:

Copied!
 ...
@php
use Filament\Notifications\Livewire\Notifications;
use Filament\Support\Enums\Alignment;
use Filament\Support\Enums\VerticalAlignment;
use Illuminate\Support\Arr;
 
$color = $getColor() ?? 'gray';
$isInline = $isInline();
$status = $getStatus();
$title = $getTitle();
$hasTitle = filled($title);
$date = $getDate();
$hasDate = filled($date);
$body = $getBody();
$hasBody = filled($body);
@endphp
 
<x-filament-notifications::notification
:notification="$notification"
:x-transition:enter-start="
Arr::toCssClasses([
'opacity-0',
($this instanceof Notifications)
? match (static::$alignment) {
Alignment::Start, Alignment::Left => '-translate-x-12',
Alignment::End, Alignment::Right => 'translate-x-12',
Alignment::Center => match (static::$verticalAlignment) {
VerticalAlignment::Start => '-translate-y-12',
VerticalAlignment::End => 'translate-y-12',
default => null,
},
default => null,
}
: null,
])
"
:x-transition:leave-end="
Arr::toCssClasses([
'opacity-0',
'scale-95' => ! $isInline,
])
"
@class([
'fi-no-notification w-full overflow-hidden transition duration-300',
...match ($isInline) {
true => [
'fi-inline',
],
false => [
'max-w-sm rounded-xl bg-white shadow-lg ring-1 dark:bg-gray-900',
match ($color) {
'gray' => 'ring-gray-950/5 dark:ring-white/10',
default => 'fi-color-custom ring-custom-600/20 dark:ring-custom-400/30',
},
is_string($color) ? 'fi-color-' . $color : null,
'fi-status-' . $status => $status,
],
},
])
@style([
\Filament\Support\get_color_css_variables(
$color,
shades: [50, 400600],
alias'notifications::notification',
) => ! ($isInline || $color === 'gray'),
])
>
<div
@class([
'flex w-full gap-3 p-4',
match ($color) {
'gray' => null,
default => 'bg-custom-50 dark:bg-custom-400/10',
},
])
>
@if ($icon = $getIcon())
<x-filament-notifications::icon
:color="$getIconColor()"
:icon="$icon"
:size="$getIconSize()"
/>
@endif
 
<div class="mt-0.5 grid flex-1">
 
@if ($hasTitle)
<x-filament-notifications::title>
{{ str($title)->sanitizeHtml()->toHtmlString() }}
</x-filament-notifications::title>
@endif
 
@if ($hasDate)
<x-filament-notifications::date @class(['mt-1' => $hasTitle])>
{{ $date }}
</x-filament-notifications::date>
@endif
 
@if ($hasBody)
<x-filament-notifications::body
@class(['mt-1' => $hasTitle || $hasDate])
>
<div
x-data="{body: @js(str($body)->sanitizeHtml()->toString() )}"
x-html="body"
x-init="
Echo.private('App.Models.User.{{ auth()->user()->id }}').listen('.UpdateNotificationBody', (event) => { if (event.id == '{{$notification->getId()}}') body = event.body });
Echo.private('App.Models.User.{{ auth()->user()->id }}').listen('.CloseNotification', (event) => { if (event.id == '{{$notification->getId()}}') setTimeout(() => $dispatch('close-notification', {id: event.id}), 2000); });
"
>
{{ str($body)->sanitizeHtml()->toHtmlString() }}
</div>
</x-filament-notifications::body>
@endif
 ...
@if ($actions = $getActions())
<x-filament-notifications::actions
:actions="$actions"
@class(['mt-3' => $hasTitle || $hasDate || $hasBody])
/>
@endif
</div>
 
 
<x-filament-notifications::close-button/>
</div>
</x-filament-notifications::notification>
 

Here we basically added an alpine component that listent to two events:

  • UpdateNotificationBody for updating a notification
  • CloseNotification for closing the notification when progress == 100%

Create a new helper class ðŸŽ¨

Put it wherever you want. If you read the code, it's self-explanatory.

I made the API simple enough so my users will experience the same notification throughout the project, but you can expand it if necessary. The end success notification is a bit more customizable.

Copied!
<?php
 
namespace App\Services\BackgroundTasks;
 
use App\Models\User;
use Closure;
use Exception;
use Filament\Notifications\Notification;
use Illuminate\Bus\Batch;
use Illuminate\Bus\PendingBatch;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Bus;
 
class BackgroundTasks
{
protected Notification $notification;
 
protected PendingBatch|Batch $batch;
 
protected User $user;
 
protected Notification $successNotification;
 
protected array|Collection $jobs;
 
public function __construct(array|Collection $jobs, User $user)
{
$this->jobs = $jobs;
$this->user = $user;
 
$this->notification = Notification::make()
->body('Starting...')
->icon('heroicon-o-cog-8-tooth')
->info()
->persistent();
 
$this->successNotification = Notification::make()
->title('Task executed successfully')
->success();
}
 
public static function make(array|Collection $jobs, User $user): self
{
return new self($jobs, $user);
}
 
public function setTitle(string|Closure|null $title): self
{
$this->notification->title($title);
 
return $this;
}
 
public function setIcon(string|Htmlable|Closure|null $icon): self
{
$this->notification->icon($icon);
 
return $this;
}
 
public function dispatch(): self
{
$notificationId = $this->notification->getId();
$userId = $this->user->id;
$successNotification = $this->successNotification->toArray();
 
$this->batch = Bus::batch($this->jobs)
->progress(function (Batch $batch) use ($notificationId, $userId) {
self::updateNotificationProgress($batch, $notificationId, $userId);
})
->finally(function (Batch $batch) use ($notificationId, $userId, $successNotification) {
self::updateNotificationProgress($batch, $notificationId, $userId, $successNotification);
})
->dispatch();
 
$this->notification->send();
 
return $this;
}
 
public function setSuccessNotification(Closure $callable): self
{
$this->successNotification = $callable($this->successNotification);
 
return $this;
}
 
public function getBatchId(): string
{
if ($this->batch instanceof PendingBatch) {
throw new Exception('Batch has not been dispatched yet');
}
 
return $this->batch->id;
}
 
public function getNotificationId(): string
{
return $this->notification->getId();
}
 
public static function updateNotificationProgress(Batch $batch, string $notificationId, int $userId, ?array $successNotification = null): void
{
$body = $batch->progress().'% done';
 
Broadcast::private('App.Models.User.'.$userId)
->as('UpdateNotificationBody')
->with([
'id' => $notificationId,
'body' => $body,
])
->sendNow();
 
if ($batch->finished()) {
 
if ($successNotification) {
Notification::fromArray($successNotification)->broadcast(User::find($userId));
}
Broadcast::private('App.Models.User.'.$userId)
->as('CloseNotification')
->with(['id' => $notificationId])
->send();
}
}
}

Use it on your project

Now we take our previous code, and use our knewly created BackgroundTasks helper!

Copied!
#[Renderless]
public function checkIssues()
{
$jobs = $this->zone->records()->get()
->map(fn ($record) => new CheckZoneRecord($record));
 
BackgroundTasks::make($jobs, $this->user)
->setTitle('Checking for new issues')
->setSuccessNotification(function (Notification $notification) {
return $notification->title('Issues checked !');
})
->dispatch();
}

And here it is !

Security concern

Since I know that I'm not using any HTML in my notifications, my version uses x-text. In this article, I changed it to x-html so it will not break your application. That should not be a problem security-wise, but be aware that the body sent through the websocket is not sanitized by str($body)->sanitizeHtml() neither by AlpineJS.

Basically, if your websocket server is compromised (or if you use a Public channel, I'm not sure): the attacker could send any HTML he wants in your DOM.

If you don't use any HTML in your notification, change it to x-text to be sure. Or read the last part of this article where I explain how to do even better by customizing even more Filament.

How can we do more?

I don't know what's on the roadmap of Filament v4 but I hope this feature will be included in the next release. Or at least I hope this article will push someone make a PR to Filament.

I almost made a package of it but I don't think this version is worth it.

If you are brave enough, here is a list of things I would do differently:

  • Support for database notifications: For now, only broadcasted notification support this feature but it should be easy enought to update the notification in the database.
  • Add a new variable to mark the notification as updateable so they could be rebroadcasted if you open a new tab. Like a really persistent notification.
  • Make a sub livewire component for each Notification to clean the code and have them listen to events dedicated to them. This will also help preventing XSS.

Hope you liked this article!

#laravel #filament #reverb #livewire

Syntax highlighting provided by torchlight.dev