Upgrade your Filament notifications by live updating them
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.
#[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:
- Sending
CheckZoneRecord
jobs to the queues - 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.
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:
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
php artisan make:component notification --view
Copy and paste the content of the original file and and modify the body part:
@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, 400, 600], 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.
<?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!
#[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!
Syntax highlighting provided by torchlight.dev