Success and failure notifications in Filament

Overview

Within any project, finding yourself implementing the same pattern of logic over and over again with little variation is typically a strong signal for refactoring the logic into a reusable mechanism.

In my case, this signal to refactor came up when implementing logic to send a success or failure Filament notification to alert my users of the result of their action.

This article is a deep dive into a simple trait for a Filament project, which made it quicker and easier to maintain a consistent notification feedback pattern throughout the application.

Use case

This trait is useful for performing operations that may fail and providing the user feedback via Filament Notification regarding if the operation was successful.

Examples of when you may find this trait useful include operations that interact with a database, cache, filesystem, or third-party system and are processed synchronously; one triggered by a user's interaction with the web application.

In a Filament application this would typically be in a Filament action or a Livewire event listener.

Assumptions

You have a working Laravel application with Filament 4 installed and configured.

Example invocations

Sending a result notification after an operation completes with:

  • reporting any exception or error
  • without a database transaction
  • default notification titles
  • no notification bodies
  • default notification icons
1static::resultNotificationOperation(
2 callback: function () {
3 // your operation here
4 },
5);

Sending a result notification after an operation completes with:

  • no reporting of any exception or error
  • within a database transaction
  • custom notification titles
  • custom notification bodies
  • custom notification icons
1static::resultNotificationOperation(
2 callback: function () {
3 // your operation here
4 },
5 report: false,
6 transact: true,
7 successTitle: 'Save successful',
8 successBody: 'Your changes have been saved.',
9 successIcon: \Filament\Support\Icons\Heroicon::Bell,
10 failureTitle: 'Save failed',
11 failureBody: 'An error occurred while saving your changes.',
12 failureIcon: \Filament\Support\Icons\Heroicon::ExclamationCircle,
13);

Sending a result notification after an operation completes with:

  • success notification title that depends on a value returned by the callback function
1static::resultNotificationOperation(
2 callback: fn () => User::where('email', 'like', '%@gmail.com')->update(['is_gmail' => true]),
3 successTitle: fn (int $count) => "Updated $count " . Str::plural('user', $count) . '.',
4);
Notification title, body, and icon parameters all accept a Closure to allow for flexibility. The parameters successTitle, successBody, and successIcon have the return value of the callback function passed as the first argument.

HasResultNotificationOperations trait

Below is the trait HasResultNotificationOperations that adds a protected static function named resultNotificationOperation to any class that uses it. Later in this article, we'll examine how the function works in depth.

I created this trait in app/Concerns/Filament/Pages/, but use any directory in your application that makes sense for your application.

📁
app/Concerns/Filament/Pages/HasResultNotificationOperations.php
1<?php
2 
3namespace App\Concerns\Filament\Pages;
4 
5use BackedEnum;
6use Closure;
7use Filament\Notifications\Notification;
8use Filament\Support\Icons\Heroicon;
9use Illuminate\Support\Facades\DB;
10use Throwable;
11 
12trait HasResultNotificationOperations
13{
14 protected static function resultNotificationOperation(
15 Closure $callback,
16 bool $report = true,
17 bool $transact = false,
18 string|Closure|null $successTitle = 'Operation Successful',
19 string|Closure|null $successBody = null,
20 BackedEnum|Closure $successIcon = Heroicon::CheckCircle,
21 string|Closure|null $failureTitle = 'Whoops! Something went wrong.',
22 string|Closure|null $failureBody = null,
23 BackedEnum|Closure $failureIcon = Heroicon::XCircle,
24 ): void {
25 try {
26 $data = $transact ? DB::transaction(fn () => $callback()) : $callback();
27 
28 static::sendNotification(
29 isSuccess: true,
30 title: static::callOrDefault($successTitle, $data),
31 body: static::callOrDefault($successBody, $data),
32 icon: static::callOrDefault($successIcon, $data),
33 );
34 } catch (Throwable $th) {
35 if ($report) {
36 report($th);
37 }
38 
39 static::sendNotification(
40 isSuccess: false,
41 title: static::callOrDefault($failureTitle, $th),
42 body: static::callOrDefault($failureBody, $th),
43 icon: static::callOrDefault($failureIcon, $th),
44 );
45 }
46 }
47 
48 private static function callOrDefault(string|BackedEnum|Closure|null $callbackOrDefault, mixed $dataOrThrowable = null): mixed
49 {
50 return match (true) {
51 $dataOrThrowable !== null && $callbackOrDefault instanceof Closure => $callbackOrDefault($dataOrThrowable),
52 $callbackOrDefault instanceof Closure => $callbackOrDefault(),
53 default => $callbackOrDefault,
54 };
55 }
56 
57 private static function sendNotification(bool $isSuccess, ?string $title, ?string $body, ?BackedEnum $icon): void
58 {
59 if (! $title) {
60 return;
61 }
62 
63 Notification::make()
64 ->when(
65 value: $isSuccess,
66 callback: fn (Notification $notification) => $notification->success(),
67 default: fn (Notification $notification) => $notification->danger(),
68 )
69 ->title($title)
70 ->body($body)
71 ->icon($icon)
72 ->send();
73 }
74}

How the trait works

To examine how the protected static function in this trait works, let's work backwards from bottom-to-top of the file.

sendNotification private function

1private static function sendNotification(bool $isSuccess, ?string $title, ?string $body, ?BackedEnum $icon): void
2{
3 if (! $title) {
4 return;
5 }
6 
7 Notification::make()
8 ->when(
9 value: $isSuccess,
10 callback: fn (Notification $notification) => $notification->success(),
11 default: fn (Notification $notification) => $notification->danger(),
12 )
13 ->title($title)
14 ->body($body)
15 ->icon($icon)
16 ->send();
17}

The sendNotification function first checks if the $title argument is falsy. If it is, the function does nothing else and returns early.

Otherwise, the function sends a Filament notification. The make static method on Filament\Notifications\Notification creates a new instance of the Notification class.

Every method chained onto Notification instance above returns $this which allows for chaining methods.

The when function is made available to the Notification class via the trait Illuminate\Support\Traits\Conditionable.

This function will:

  1. Check if the provided value is truthy
  2. If it is, the Closure provided to the callback parameter is executed with the instance of the class as the first argument
  3. If it is not, the Closure provided to the default parameter is executed with the instance of the class as the first argument

In the sendNotification function, we are evaluating if the $isSuccess variable is true. If it is, we call success() on the Notification instance. Otherwise, we call danger() on the Notification instance.

The title, body, and icon functions all set the corresponding property on the Notification instance to the provided value.

Finally, the send function will push the notification into the current session as an array of data.

callOrDefault private function

1private static function callOrDefault(string|BackedEnum|Closure|null $callbackOrDefault, mixed $dataOrThrowable = null): mixed
2{
3 return match (true) {
4 $dataOrThrowable !== null && $callbackOrDefault instanceof Closure => $callbackOrDefault($data),
5 $callbackOrDefault instanceof Closure => $callbackOrDefault(),
6 default => $callbackOrDefault,
7 };
8}

The callOrDefault private function is a function which will:

  1. if $dataOrThrowable is not null and $callbackOrDefault is a Closure
    • return the value returned by calling the $callbackOrDefault Closure, with $dataOrThrowable as the first argument
  2. if $dataOrThrowable is null and $callbackOrDefault is a Closure
    • return the value returned by calling the $callbackOrDefault Closure, with no arguments
  3. otherwise
    • return the value $callbackOrDefault as provided
A limitation of the logic above is that null should never be returned by the callback function if the value needs to be passed to a Closure for a success notification detail. Use a scalar value instead (or throw an exception in case of failure).

resultNotificationOperation protected function

1protected static function resultNotificationOperation(
2 Closure $callback,
3 bool $report = true,
4 bool $transact = false,
5 string|Closure|null $successTitle = 'Operation Successful',
6 string|Closure|null $successBody = null,
7 BackedEnum|Closure $successIcon = Heroicon::CheckCircle,
8 string|Closure|null $failureTitle = 'Whoops! Something went wrong.',
9 string|Closure|null $failureBody = null,
10 BackedEnum|Closure $failureIcon = Heroicon::XCircle,
11): void {
12 try {
13 $data = $transact ? DB::transaction(fn () => $callback()) : $callback();
14 
15 static::sendNotification(
16 isSuccess: true,
17 title: static::callOrDefault($successTitle, $data),
18 body: static::callOrDefault($successBody, $data),
19 icon: static::callOrDefault($successIcon, $data),
20 );
21 } catch (Throwable $th) {
22 if ($report) {
23 report($th);
24 }
25 
26 static::sendNotification(
27 isSuccess: false,
28 title: static::callOrDefault($failureTitle, $th),
29 body: static::callOrDefault($failureBody, $th),
30 icon: static::callOrDefault($failureIcon, $th),
31 );
32 }
33}

Now for the protected static function resultNotificationOperation, which uses the two previously described functions.

This function is wrapped in try and catch blocks. The Throwable hint is used in the catch block because it will catch both Exceptions and Errors.

Within the try block, the function will first set the value of the $data variable. If the $transact argument provided is true, the provided $callback argument will be invoked within a database transaction. Otherwise, the $callback will be invoked without being wrapped in a database transaction.

Next, the function will send the success notification (because the operation contained by $callback must not have thrown any Exceptions or Errors for the function to reach this point).

  • true is provided to the isSuccess parameter
  • the provided $successTitle in combination with the $data variable are used to determine the value provided to the title parameter
  • same for $successBody
  • same for $successIcon

In the event an Exception or Error is thrown when executing the $callback function, the catch block will:

  1. if $report is true
    • use the report helper method to report the Exception or Error
  2. send the failure notification
    • false is provided to the isSuccess parameter
    • the provided $failureTitle in combination with the caught $th (Throwable) are used to determine the value provided to the title parameter
    • same for $failureBody
    • same for $failureIcon
Note that the first argument passed to the title, body, and icon functions is either the return value of the $callback function or the caught Exception or Error, depending on if the operation was successful.

Conclusion

With this trait, I've been able to maintain consistency of providing feedback to the user throughout my Filament application. Both my experiences as a developer and the experiences of my users have benefited.

🤖
Did you spot a mistake in this article? Have a suggestion for how something can be improved? Even if you'd just like to comment or chat about something else, I'd love to hear from you! Contact me.

Syntax highlighting by Torchlight.dev

End of article