Notifications
Notify the author after the same PostPublished event:
final author = await post.user().get();if (author != null) { await author.notify(PostPublishedNotification(post));}Sistrum (sistrum) delivers one notification through mail, database, or
application-defined channels. A notification owns its content and channel
choice; a notifiable object owns its delivery routes and database identity.
New Maat applications register NotificationServiceProvider and include
sistrumMigrations automatically. In an existing application, add both:
.withProviders([ MailServiceProvider.new, NotificationServiceProvider.new,])
final migrations = <Migration>[ ...sistrumMigrations,];Run maat migrate before using the database channel.
Defining a Notification
Generate the class:
maat make:notification PostPublishedNotificationChoose channels per recipient and provide each channel’s payload:
class PostPublishedNotification extends Notification { PostPublishedNotification(this.post);
final Post post;
@override List<String> via(Object notifiable) => const ['mail', 'database'];
@override MailMessage toMail(Object notifiable) => MailMessage() .subject('Your post is live') .greeting('Published') .line('"${post.title}" is now available to readers.') .action('View post', 'https://example.com/posts/${post.id}') .line('Thank you for writing.') .success();
@override Map<String, Object?> toArray(Object notifiable) => { 'postId': post.id, 'title': post.title, };}toDatabase() wraps toArray() by default. Override it with a
DatabaseMessage when the two payloads differ. Override databaseType() when
stored type names must remain stable across Dart class renames.
MailMessage supports subject, greeting, line, lineIf, lines,
action, salutation, success, error, from, cc, bcc, replyTo,
attach, view, and mailer. Without a subject, Sistrum converts the
notification class name to a headline. Without a view, it renders safe built-in
HTML plus a plain-text alternative; a custom Khnum view replaces only HTML.
Notifiable Models
Mix Notifiable into a Seshat model. Its model key and runtime type become the
database notification identity:
class User extends Model<User> with Notifiable { @override Object? routeNotificationFor(String channel) => switch (channel) { 'mail' => email, _ => null, };}The mail channel uses routeNotificationFor('mail') first, then falls back to
a String email property. A route may be a String, Address, or iterable of
either.
Sending
await author.notify(PostPublishedNotification(post));await Notification.send([firstUser, secondUser], WeeklyDigest());
await user.notifyNow( SecurityAlert(), channels: const ['database'],);
await Notification.route( 'mail', 'ops@example.com',).notify(ServiceRecovered());An anonymous/on-demand recipient cannot use the database channel because it has no stable type and key.
Use shouldSend for a final per-recipient, per-channel decision:
@overridebool shouldSend(Object notifiable, String channel) => channel != 'mail' || (notifiable as User).acceptsEmail;Database Notifications
final all = await user.notifications().get();final unread = await user.unreadNotifications().get();final read = await user.readNotifications().get();
final opened = await unread.first.markAsRead();await opened.markAsUnread();Queries are newest first. State changes return a new
DatabaseNotification, following Seshat’s immutable model convention.
Custom Channels
class SmsChannel implements NotificationChannel { @override Future<Object?> send(Object notifiable, Notification notification) async { final number = (notifiable as User).phone; return sms.send(number, notification.toArray(notifiable)); }}
Notification.extend('sms', SmsChannel());Register a custom channel before sending a notification that lists its name.
Delivery Events
NotificationSending fires before each channel. Return false to skip it:
Event.listen<NotificationSending>((event) { if (event.channel == 'mail' && maintenanceMode) return false;});
Event.listen<NotificationSent>((event) { Log.info('Sent ${event.notification.runtimeType} on ${event.channel}');});
Event.listen<NotificationSkipped>((event) { Log.info('Skipped ${event.channel}');});
Event.listen<NotificationFailed>((event) { Log.error('Failed ${event.channel}: ${event.error}');});After NotificationFailed, the original channel error is rethrown.
Testing
final notifications = Notification.fake();
await author.notify(PostPublishedNotification(post));
notifications.assertSentTo<PostPublishedNotification>(author);notifications.assertSentTo<PostPublishedNotification>( author, (message, channels) => message.post.id == post.id && channels.contains('database'),);notifications.assertSentToTimes<PostPublishedNotification>(author, 1);notifications.assertNotSentTo<PasswordResetNotification>(author);notifications.assertCount(1);For on-demand recipients, use assertSentOnDemand<T>(). Other helpers are
sent<T>(), assertNothingSent(), assertNothingSentTo(), and
assertSentTimes<T>().
Sends are inline: the returned future completes after all selected channels.
Queued notifications arrive with the queue package, not as a hidden behavior
of send().