---
title: "Notification types — Notifications for Laravel | Roundly"
description: "Register each kind of notification once — a NotificationType class or a fluent define() — with every hook, its default and the payload."
url: https://roundly-consulting.com/open-source/docs/notifications-for-laravel/notification-types
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Notifications for Laravel](https://roundly-consulting.com/open-source/docs/notifications-for-laravel.md)

# 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:

```php
// 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:

```php
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:

```php
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():

```php
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:

```php
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

```php
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

```php
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:

```bash
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/notifications-for-laravel.md)
