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
| Method | Default | Meaning |
|---|---|---|
key(): string | abstract | ^[a-z0-9_]+(\.[a-z0-9_]+)*$, at most 100 characters — never looks like a class name. |
title(NotificationPayload): string | abstract | The inbox title and the mail subject. |
category(): string | general | The same pattern; groups the preferences matrix and inbox filters. |
label(): string | your notifications.types.<key>.label line, else the key in headline case | Translated at read time. |
description(): ?string | your notifications.types.<key>.description line, else null | — |
channels(): list<Channel> | [Channel::Inbox, Channel::Mail] | At least one. |
enabledByDefault(Channel): bool | true | false makes a channel opt-in. |
mandatory(): bool | false | Ignores every mute; a write that would mute it throws MandatoryNotificationException. |
priority(): Priority | Normal | Low / Normal / Urgent. Urgent bypasses quiet hours, digests and trailing mail. |
digestible(): bool | false | A digest needs Low and digestible; a digestible non-Low type is refused at registration. |
window(): CarbonInterval|string|null | null | ISO-8601 or relative ('10 minutes'), at most one day. null: no collapse, no de-dup. |
mailStrategy(): MailStrategy | mail.strategy | Trailing waits the window; urgent types always lead. |
subject(): ?string | null | The subject model class. A send with another class throws InvalidNotificationSubjectException; a type without one refuses a subject. |
subjectFrom(Notification): ?Model | null | Interop only — the subject of another package’s notification. |
canAccess(Model $recipient, ?Model $subject): bool | Gate notifications.access.ability | See Recipients and access. |
allowsTrashedSubject(): bool | false | A soft-deleted subject counts as gone at every hop unless true. |
minimalTitle(): string | notifications::mail.minimal_subject | The subject line of a minimal mail. |
context(?Model $subject) | null | A Context, a model or a string — the default context when the sender gives none. |
notifyActor(): bool | false | The actor is dropped from the recipients unless true. |
sensitive(?Model $subject): bool | false | Minimal mail: a title and a link only. |
body(NotificationPayload): ?string | null | The mail’s first line (else the title). |
url(NotificationPayload): ?string | null | Absolute http(s) only — anything else is never rendered. |
actionText(NotificationPayload): string | notifications::mail.view | The button label. |
digestLine(NotificationPayload): string | title() | The line in a digest. |
mail(MailMessage, NotificationPayload): MailMessage | identity | The last hook to tweak a mail (not called for minimal mail). |
publicData(array $data): array | $data | What 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();| Method | Meaning |
|---|---|
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(): void | Build 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.
| Cause | Exception |
|---|---|
| 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 model | InvalidNotificationTypeException |
| 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 rule | InvalidReminderRuleException |
| 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 itselfCategory 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 rulesShow 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 cryptoBy 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.