NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Notifications for Laravel

Notification types

A type is one kind of notification with a stable key (request.awaiting_approval): its category, channels, priority, whether it is mandatory or digestible, its collapse window, the subject it is about and how it renders. Write it as a small final class extending NotificationType and list it in config — only key() and title() are required:

// config/notifications.php
'types' => [
    App\Notifications\Types\RequestAwaitingApproval::class,
    App\Notifications\Types\ContractExpiring::class,
    App\Notifications\Types\SecurityAlert::class,
],

A mandatory, urgent, sensitive type — nobody can mute it, quiet hours never hold it and its mail carries no detail:

use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Notifications\Enums\Priority;
use RoundlyConsulting\Notifications\NotificationPayload;
use RoundlyConsulting\Notifications\NotificationType;

final class SecurityAlert extends NotificationType
{
    public function key(): string { return 'security.alert'; }
    public function category(): string { return 'security'; }
    public function mandatory(): bool { return true; }                 // nobody can mute it
    public function priority(): Priority { return Priority::Urgent; }  // never held by quiet hours
    public function sensitive(?Model $subject): bool { return true; }  // a title + link mail only

    public function title(NotificationPayload $p): string
    {
        return __('app.security_alert', ['what' => $p->data['what'] ?? 'activity']);
    }

    public function url(NotificationPayload $p): ?string { return route('account.security'); }
}

Hooks and defaults

MethodDefaultMeaning
key(): stringabstract^[a-z0-9_]+(\.[a-z0-9_]+)*$, at most 100 characters — never looks like a class name.
title(NotificationPayload): stringabstractThe inbox title and the mail subject.
category(): stringgeneralThe same pattern; groups the preferences matrix and inbox filters.
label(): stringyour notifications.types.<key>.label line, else the key in headline caseTranslated at read time.
description(): ?stringyour notifications.types.<key>.description line, else null—
channels(): list<Channel>[Channel::Inbox, Channel::Mail]At least one.
enabledByDefault(Channel): booltruefalse makes a channel opt-in.
mandatory(): boolfalseIgnores every mute; a write that would mute it throws MandatoryNotificationException.
priority(): PriorityNormalLow / Normal / Urgent. Urgent bypasses quiet hours, digests and trailing mail.
digestible(): boolfalseA digest needs Low and digestible; a digestible non-Low type is refused at registration.
window(): CarbonInterval|string|nullnullISO-8601 or relative ('10 minutes'), at most one day. null: no collapse, no de-dup.
mailStrategy(): MailStrategymail.strategyTrailing waits the window; urgent types always lead.
subject(): ?stringnullThe subject model class. A send with another class throws InvalidNotificationSubjectException; a type without one refuses a subject.
subjectFrom(Notification): ?ModelnullInterop only — the subject of another package’s notification.
canAccess(Model $recipient, ?Model $subject): boolGate notifications.access.abilitySee Recipients and access.
allowsTrashedSubject(): boolfalseA soft-deleted subject counts as gone at every hop unless true.
minimalTitle(): stringnotifications::mail.minimal_subjectThe subject line of a minimal mail.
context(?Model $subject)nullA Context, a model or a string — the default context when the sender gives none.
notifyActor(): boolfalseThe actor is dropped from the recipients unless true.
sensitive(?Model $subject): boolfalseMinimal mail: a title and a link only.
body(NotificationPayload): ?stringnullThe mail’s first line (else the title).
url(NotificationPayload): ?stringnullAbsolute http(s) only — anything else is never rendered.
actionText(NotificationPayload): stringnotifications::mail.viewThe button label.
digestLine(NotificationPayload): stringtitle()The line in a digest.
mail(MailMessage, NotificationPayload): MailMessageidentityThe last hook to tweak a mail (not called for minimal mail).
publicData(array $data): array$dataWhat InboxItemResource exposes as data.
reminders(): list<ReminderRule>[]See Reminders and escalation.

The payload

Every rendering hook receives a final readonly NotificationPayload. Read subjectKey and data for anything that must survive the subject’s deletion:

public function title(NotificationPayload $p): string
{
    $p->type;         // 'request.awaiting_approval'
    $p->recipient;    // the recipient model
    $p->subject;      // the fresh subject — null when it is gone
    $p->subjectKey;   // survives the subject's deletion
    $p->actor;        // the latest actor
    $p->context;      // ?ContextReference: key, label, type, id
    $p->data;         // the latest event's data
    $p->count;        // events collapsed into this item or mail
    $p->firstAt;      // CarbonImmutable, UTC
    $p->lastAt;
    $p->channel;      // Channel::Inbox or Channel::Mail
    $p->escalated;    // true on an escalation send
    $p->reminder;     // ?ReminderReference: id, rule, offset ('-P30D'), dueAt, level

    return trans_choice('app.awaiting', $p->count, ['ref' => $p->data['reference'], 'count' => $p->count]);
}

Inbox titles, bodies and urls render at read time in the viewer’s locale from the live type. Every inbox write also stores a snapshot title and url in the recipient’s locale; the resource falls back to it when the type is no longer registered or its render throws.

Fluent types

A type without a class, registered from a service provider’s boot():

use RoundlyConsulting\Notifications\Enums\Priority;
use RoundlyConsulting\Notifications\Facades\Notifications;
use RoundlyConsulting\Notifications\NotificationPayload;

// AppServiceProvider::boot()
Notifications::define('comment.posted')
    ->category('comments')
    ->priority(Priority::Low)
    ->digestible()
    ->window('PT10M')
    ->subject(Post::class)
    ->title(fn (NotificationPayload $p): string => trans_choice('app.comment', $p->count, ['count' => $p->count]))
    ->url(fn (NotificationPayload $p): ?string => route('posts.show', $p->subjectKey))
    ->register();

Every class hook has a TypeBuilder counterpart:

use RoundlyConsulting\Notifications\Enums\Channel;
use RoundlyConsulting\Notifications\Enums\MailStrategy;

Notifications::define('report.ready')
    ->label('Report ready', 'When an export you requested has finished.')
    ->channels(Channel::Inbox, Channel::Mail)
    ->optIn(Channel::Mail)                                   // mail off until the user switches it on
    ->mailStrategy(MailStrategy::Trailing)
    ->window('PT15M')
    ->subject(Report::class, allowsTrashed: true)
    ->access(fn (User $recipient, ?Report $report): bool => $report?->owner_id === $recipient->id)
    ->context(fn (?Report $report) => $report?->workspace)
    ->sensitive(fn (?Report $report): bool => $report?->confidential === true)
    ->body(fn (NotificationPayload $p): ?string => __('app.report_ready_body'))
    ->mail(fn (MailMessage $message, NotificationPayload $p): MailMessage => $message->line(__('app.report_expiry')))
    ->register();
MethodMeaning
category($category)The category.
label($label, $description = null)A literal label and description (a class type reads them from your translations).
channels(Channel ...$channels) / optIn(Channel $channel)The channels; optIn() makes one off by default.
mandatory() / priority(Priority) / digestible()The flags of the class hooks.
window($window) / mailStrategy(MailStrategy)Collapse window and mail strategy.
subject($class, allowsTrashed: false)The subject class (and whether a soft-deleted one still counts).
notifyActor()Keep the actor among the recipients.
title(fn) / body(fn) / url(fn)Rendering closures taking the NotificationPayload. Without title() the label is the title.
access(fn ($recipient, $subject): bool)Your own access check instead of the Gate.
context(fn ($subject)) / sensitive(fn ($subject): bool)The default context; minimal mail.
mail(fn ($message, $payload)) / subjectFrom(fn ($notification))Tweak the mail; the interop subject.
reminders(ReminderRule ...$rules)Reminder rules.
make(): DefinedType / register(): voidBuild the type, or build and register it.

Registration

Notifications::register(RequestAwaitingApproval::class, SecurityAlert::class);   // class-strings
Notifications::registerType(app(RequestAwaitingApproval::class));                  // an instance

$type = Notifications::define('digest.preview')->title(fn () => 'Preview')->make(); // a DefinedType, not registered
Notifications::registerType($type);

// Anywhere a type key is accepted, a type class-string works too
Notifications::type(RequestAwaitingApproval::class)->about($request)->to($approvers)->send();
  • Types in config('notifications.types') are loaded lazily on first use and resolved through the container, so they may take constructor dependencies.
  • Validation runs at registration with no I/O, so package:discover works before migrating.
  • The registry is per process; queued jobs re-resolve types by key, and an unknown key at delivery time cancels the row (unknown_type).
  • Anywhere a type key is accepted, a type class-string works too.
CauseException
A key or category that breaks the pattern; a digestible type that is not Low; a window that is not a positive duration of at most a day; no channel; a subject that is not a modelInvalidNotificationTypeException
A second registration of one key — naming both sources by basename or define()DuplicateNotificationTypeException
Reminder rules on a type without a subject, an empty attribute, 0 or more than 25 offsets, a duplicate rule key, at() in a type ruleInvalidReminderRuleException
A key nobody registered (on send, find(), a preference write)UnknownNotificationTypeException

Discovery for a settings screen

Notifications::types()->all();          // list<TypeDefinition>: key, category, label, description, channels,
                                        //   defaultChannels, mandatory, priority, digestible
Notifications::types()->categories();   // list<CategoryDefinition>: key + label
Notifications::types()->find($key);     // TypeDefinition
Notifications::types()->has($key);      // bool
Notifications::types()->get($key);      // the NotificationType itself

Category labels come from your notifications.categories.<key> line, else the package’s fallback for general, else the key in headline case. The registry is printed by:

php artisan notifications:types   # Key, Category, Channels, Priority, Flags, Window, Reminder rules

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.