An import job finished twenty minutes ago. The admin who started it navigated away ten minutes before that, and nothing in the panel has told them the work is done. A toast cannot solve this — toasts live in the session and die on the next page load. What you want is the notifications drawer, and Filament v5 turns it on with one method call that hides two things worth knowing about: the exact payload shape it queries for, and a polling interval you almost certainly want to replace.
Create the notifications table#
Filament database notifications are ordinary Laravel database notifications, so the storage is Laravel's notifications table. Publish the migration and run it before touching the panel — without the table the drawer throws on first render rather than degrading quietly.
php artisan make:notifications-table
php artisan migrate
Two schema notes from the Filament docs that bite later. On PostgreSQL the data column must be $table->json('data'), not text, because Filament queries inside it with a JSON path. If your User model uses UUIDs, change morphs('notifiable') to uuidMorphs('notifiable') in the published migration.
Enable the drawer on your panel#
Add databaseNotifications() to the panel provider. That single call registers the trigger in the topbar, mounts the DatabaseNotifications Livewire component and gives you the unread badge.
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
// ...
->databaseNotifications()
// 30s is the framework default; state it so the next person sees it.
->databaseNotificationsPolling('30s');
}
If you have disabled the topbar, or you simply want the bell beside the navigation, move it:
use Filament\Enums\DatabaseNotificationsPosition;
->databaseNotifications(position: DatabaseNotificationsPosition::Sidebar);
This is a different subsystem from the transient toasts you get after an action. Those are session-backed and disappear on navigation — I covered that side in custom Filament actions with confirmation modals and notifications. The drawer is database-backed and survives a reload, a logout and a different device.
Send a notification the drawer will actually show#
The fluent API writes a row immediately. Use it from a controller, an action or a job.
use Filament\Notifications\Notification;
Notification::make()
->title('Import complete')
->body('4,812 products were imported.')
->success()
->sendToDatabase($recipient);
Look at what that writes to data, because this is the part that causes the most wasted hours:
{
"actions": [],
"body": "4,812 products were imported.",
"color": null,
"duration": "persistent",
"icon": "heroicon-o-check-circle",
"iconColor": "success",
"status": "success",
"title": "Import complete",
"view": null,
"viewData": [],
"format": "filament"
}
format: filament is not decoration. Filament's DatabaseNotifications component builds its query as $user->notifications()->where('data->format', 'filament'), and the unread badge counts the same query. A row without that key is not rendered blank — it does not exist as far as the drawer is concerned, and the badge will not count it either. duration: persistent is the other key getDatabaseMessage() injects, and it is what stops the row behaving like a timed toast.
Build the notification as a Laravel notification class#
Inline Notification::make() is fine for an action handler. Once a job is doing the sending you want a real notification class, because that is where the queue, the retry policy and the test seam live. Return getDatabaseMessage() from toDatabase() and you get the correct payload for free.
php artisan make:notification ImportCompleted
<?php
namespace App\Notifications;
use App\Models\Import;
use App\Models\User;
use Filament\Notifications\Notification as FilamentNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class ImportCompleted extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public Import $import) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* @return array<string, mixed>
*/
public function toDatabase(User $notifiable): array
{
return FilamentNotification::make()
->title('Import complete')
->body("{$this->import->row_count} rows were imported.")
->success()
->getDatabaseMessage();
}
}
Never hand-roll that array. getDatabaseMessage() is one method call and it stays correct when Filament adds a key; a literal ['title' => '…', 'body' => '…'] silently drops format and the notification vanishes.
Add actions to the drawer row#
A drawer full of statements is a log. A drawer with buttons is a workflow. Filament actions render inside the row and can mark the notification read as a side effect of being clicked.
use Filament\Actions\Action;
use Filament\Notifications\Notification as FilamentNotification;
return FilamentNotification::make()
->title('Import complete')
->body("{$this->import->row_count} rows were imported.")
->success()
->actions([
Action::make('view')
->button()
->url(ImportResource::getUrl('view', ['record' => $this->import]))
->markAsRead(),
])
->getDatabaseMessage();
markAsRead() sets read_at and decrements the badge. markAsUnread() does the opposite, which is useful for a "remind me" affordance on something the user opened but did not deal with.
Connect the panel to Reverb#
Polling every 30 seconds is honest but wasteful: forty open tabs is eighty requests a minute that almost always return nothing. Install broadcasting and Reverb, which sets up the server, the config and the Echo client in one command.
php artisan install:broadcasting
php artisan reverb:start
Filament reads its Echo settings from its own config file rather than your resources/js/echo.js, which is the step most people miss. Publish the config and uncomment the broadcasting.echo block.
php artisan vendor:publish --tag=filament-config
// config/filament.php
'broadcasting' => [
'echo' => [
'broadcaster' => 'reverb',
'key' => env('VITE_REVERB_APP_KEY'),
'wsHost' => env('VITE_REVERB_HOST'),
'wsPort' => env('VITE_REVERB_PORT', 80),
'wssPort' => env('VITE_REVERB_PORT', 443),
'authEndpoint' => '/broadcasting/auth',
'disableStats' => true,
'encrypted' => true,
'forceTLS' => env('VITE_REVERB_SCHEME', 'https') === 'https',
],
],
Then clear the caches, because both the route list and the config are involved:
php artisan route:clear
php artisan config:clear
If Reverb itself is new to you, the setup and the Echo side are covered in real-time notifications with Laravel Reverb and Echo, and if you would rather not run Redis for it, Reverb's database driver removes that dependency.
Broadcast the notification and turn off polling#
Filament does not broadcast the notification body over the socket. It broadcasts a DatabaseNotificationsSent event that tells the browser to re-fetch, which keeps the payload small and the authorization in one place. Opt in with the second argument:
Notification::make()
->title('Import complete')
->success()
->sendToDatabase($recipient, isEventDispatched: true);
From a notification class, dispatch the event yourself after sending:
use Filament\Notifications\Events\DatabaseNotificationsSent;
Notification::send($user, new ImportCompleted($import));
DatabaseNotificationsSent::dispatch($user);
That event broadcasts as database-notifications.sent on a private channel named after the notifiable — App.Models.User.17 for user 17, with backslashes replaced by dots. If your User model defines receivesBroadcastNotificationsOn(), that name wins instead. Laravel's default channels.php already authorises this pattern:
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{id}', function (User $user, int $id): bool {
return $user->id === $id;
});
Once a notification arrives without a refresh, remove the redundant poll:
->databaseNotifications()
->databaseNotificationsPolling(null);
Leaving both on is the most common misconfiguration here. You get the live update, then a full component refresh thirty seconds later that changes nothing.
Notify from a queued job#
The whole point of the drawer is telling someone about work that outlived their attention span. Inject the user who started it and notify them when the job finishes.
<?php
namespace App\Jobs;
use App\Models\Import;
use App\Models\User;
use App\Notifications\ImportCompleted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Notification;
class ProcessImport implements ShouldQueue
{
use Queueable;
public function __construct(
public Import $import,
public User $initiator,
) {}
public function handle(): void
{
// ... do the long-running work ...
Notification::send($this->initiator, new ImportCompleted($this->import));
}
}
Laravel sends database notifications through the queue. If no worker is running, the notification does not arrive and nothing anywhere reports an error — the job simply sits in the table. Keeping workers alive is its own problem; Supervisor for Laravel queue workers in production is how I handle it on a VPS.
Prune old notifications#
Nothing in Laravel or Filament deletes read notifications. On a busy panel the table grows without limit and the drawer's paginated query slows with it. DatabaseNotification is already prunable, so you only need the schedule entry.
// routes/console.php
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Support\Facades\Schedule;
Schedule::command('model:prune', [
'--model' => [DatabaseNotification::class],
])->daily();
The default prune keeps things that are still relevant; if you want a hard cutoff, the mechanics of a custom prunable() query are in auto-deleting stale records with Laravel's Prunable trait.
Test the notification#
Two assertions matter, and they are cheap. The first is that the job sent the notification at all. The second is that the payload will actually render — which, given everything above, means asserting on format.
use App\Jobs\ProcessImport;
use App\Notifications\ImportCompleted;
use Illuminate\Support\Facades\Notification;
it('notifies the initiator when the import finishes', function (): void {
Notification::fake();
$user = User::factory()->create();
$import = Import::factory()->create(['row_count' => 4812]);
(new ProcessImport($import, $user))->handle();
Notification::assertSentTo($user, ImportCompleted::class);
});
it('writes a payload the Filament drawer can read', function (): void {
$user = User::factory()->create();
$import = Import::factory()->create(['row_count' => 4812]);
$payload = (new ImportCompleted($import))->toDatabase($user);
expect($payload['format'])->toBe('filament')
->and($payload['duration'])->toBe('persistent')
->and($payload['title'])->toBe('Import complete');
});
That second test is the one that would have saved the afternoon. Mounting the drawer component itself follows the same pattern as any other Filament page test — see testing Filament v5 resources with Pest for the livewire() entry point.
Sidestep the gotchas that waste an afternoon#
The Echo listener is conditional on having notifications. Filament renders the window.Echo.private(...).listen('.database-notifications.sent') script inside the block that only renders when the drawer already has at least one row. A brand-new user with an empty drawer will not receive their first notification in real time — it appears on the next poll or page load, and every one after that is instant. If that first-notification delay matters, keep a slow poll ('60s') rather than null.
The refresh is deliberately delayed. The listener waits 500ms before calling $refresh, which gives the database write time to land. If you dispatch DatabaseNotificationsSent before the notification row is committed, the refresh finds nothing and the badge stays put until the next poll. Dispatch after the send, not alongside it.
Broadcast auth fails silently across subdomains. A panel on admin.example.com posting to /broadcasting/auth on example.com gets a CORS or session-cookie failure that surfaces only in the browser console. Reverb connects, the channel subscription is rejected, and everything looks fine from PHP. Check the network tab for a 403 on the auth endpoint before you suspect the notification.
Marking read in another tab does not update the others. The badge only re-reads on poll or on a broadcast, and marking read does neither. With polling off, a second tab keeps showing a stale count until something else broadcasts. Polling used to paper over this.
One private channel per user means N broadcasts. Notifying a hundred-person team writes a hundred rows and fires a hundred events. That is fine for a team, expensive for a customer base. Scaling the socket layer itself is a separate exercise — Reverb in production, horizontal scaling and presence channels covers where the ceilings are.
Wrapping Up#
Get the payload right first: build every toDatabase() return value from getDatabaseMessage(), and write the test that asserts format is filament. Then add isEventDispatched: true, confirm a notification lands without a refresh, and only then set databaseNotificationsPolling(null). If Reverb is new in your stack, start with real-time notifications with Laravel Reverb and Echo and make sure your queue workers stay alive under Supervisor before you rely on any of it in production.
FAQ#
How do I enable the notifications drawer in Filament?
Run php artisan make:notifications-table and php artisan migrate to create Laravel's notifications table, then call ->databaseNotifications() on the panel in your panel provider. That registers the bell trigger in the topbar, the unread badge and the slide-over drawer. Pass position: DatabaseNotificationsPosition::Sidebar if you want the trigger beside the navigation instead.
What is the difference between Filament toast notifications and database notifications?
Toasts are stored in the Laravel session and rendered once. They fire after an action, they are transient, and they are gone after the next page load. Database notifications are rows in the notifications table, rendered in the drawer, and they survive a reload, a logout and a switch to another device. Use a toast to confirm something the user just did; use the drawer to tell them about something that happened while they were elsewhere.
How do I send real-time notifications to a Filament panel with Reverb?
Install broadcasting with php artisan install:broadcasting, publish the Filament config with php artisan vendor:publish --tag=filament-config, and uncomment the broadcasting.echo block in config/filament.php with the Reverb broadcaster and your VITE_REVERB_* values. Then send with sendToDatabase($recipient, isEventDispatched: true). Filament broadcasts a DatabaseNotificationsSent event on the user's private channel, and the drawer re-fetches when it arrives.
Why is my Filament database notification showing as blank in the drawer?
Almost always because the data column does not match what Filament reads. The drawer queries where('data->format', 'filament'), so a payload missing that key does not appear at all and the badge does not count it. If the row does appear but renders empty, the keys are wrong — Filament reads title, body, status, icon, iconColor, color and actions, so a hand-written ['message' => '…'] produces nothing. Build the array from FilamentNotification::make()->…->getDatabaseMessage() instead.
How do I stop Filament notifications from polling?
Call ->databaseNotificationsPolling(null) on the panel. Do this only after broadcasting is confirmed working, because with polling off and websockets broken the drawer updates on page load and nothing else. Note that a user with an empty drawer will not get their first notification in real time, so a slow interval such as '60s' is a reasonable compromise on panels where that first message matters.
Can I send a Filament notification to a specific user from a job?
Yes, and it is the main reason the drawer exists. Pass the user into the job constructor and call Notification::send($this->initiator, new ImportCompleted($this->import)) in handle(). Laravel delivers database notifications through the queue, so a worker must be running — if none is, the notification never arrives and no error is logged anywhere.