Developer Reference 

The Notifiable Trait 

The Notifiable trait is the only requirement for a class to receive notifications. Add it to any PHP object — Extbase entity, plain PHP class, anonymous class — and that object immediately becomes a valid notification recipient.

use Lex\Notifications\Domain\Model\Ability\Notifiable;

class MyModel
{
    use Notifiable;
}

// That's it. Now you can do:
$instance = new MyModel();
$instance->notify(new SomeNotification());
Copied!

There is no registry, no database table for recipients, no configuration. Any object with the trait can be passed to the dispatcher.

Making a Model Notifiable 

For email delivery, also add HasRouteNotificationForMail. This trait provides the routeNotificationForMail() method, which the email channel calls to resolve the recipient's email address:

use Lex\Notifications\Domain\Model\Ability\HasRouteNotificationForMail;
use Lex\Notifications\Domain\Model\Ability\Notifiable;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

class FrontendUser extends AbstractEntity
{
    use Notifiable;
    use HasRouteNotificationForMail;

    // // Retrieve from the class properties, database or other source
    public function getEmail(): string { return $this->email; }
    public function getFirstName(): ?string { return $this->firstName; }
    public function getLastName(): ?string { return $this->lastName; }
}
Copied!

The Notifiable trait exposes two methods:

// Dispatches via Symfony Messenger if the notification implements ShouldQueue
$user->notify(new OrderConfirmed($order));

// Always sends immediately, ignoring ShouldQueue
$user->notifyNow(new OrderConfirmed($order), ['mail', 'database']);
Copied!

Who Can Be a Recipient? 

Because the only requirement is the Notifiable trait, the recipient can be anything. Here are common patterns.

Frontend user notifies another frontend user

A collaboration feature where one user shares content with another:

// In a frontend plugin action
$sender = $this->notifiableFrontendUserRepository->findByUid($senderUid);
$recipients = $this->notifiableFrontendUserRepository->findByUids($recipientUids);
$this->notificationDispatcher->send($recipients, new ContentSharedWithYou($page, $sender));
Copied!

Extension notifies a backend user

A Scheduler task or service that alerts an admin when a background job fails:

// An inline class wrapping a backend user record — no persistent domain model needed
$admin = new class($backendUserRecord['email'], $backendUserRecord['realName']) {
    use Notifiable;
    use HasRouteNotificationForMail;

    protected string $firstName;
    protected string $lastName;

    public function __construct(protected readonly string $email, string $fullName) {
        $parts = explode(' ', trim($fullName), 2);
        $this->firstName = $parts[0] ?? '';
        $this->lastName  = $parts[1] ?? '';
    }
    public function getEmail(): string { return $this->email; }
    public function getFirstName(): ?string { return $this->firstName; }
    public function getLastName(): ?string { return $this->lastName; }
};
$admin->notifyNow(new SchedulerJobFailed($taskName, $errorMessage));
Copied!

Inline / anonymous notifiable (no database record needed)

Send a one-off notification to any email address without a domain model:

$recipient = new class('info@example.com') {
    use Notifiable;
    use HasRouteNotificationForMail;

    public function __construct(protected readonly string $email) {}
    public function getEmail(): string { return $this->email; }
    public function getFirstName(): ?string { return null; }
    public function getLastName(): ?string { return null; }
};

$recipient->notifyNow(new ContactFormReceived($formData));
Copied!

Multiple recipients of different types in one call

The dispatcher accepts an array of notifiables — they do not need to be the same class:

 $team = [$teamMemberA, $teamMemberB, ...]

$this->notificationDispatcher->send(
    $team,
    new ImportantAnnouncement($text),
);
Copied!

Each recipient's via() result can differ, so the notification class can adapt channels based on the notifiable type:

public function via(object $notifiable): array
{
    // Only store in-DB for actual frontend user records
    if ($notifiable instanceof NotifiableFrontendUser) {
        return [NotificationChannel::CHANNEL_MAIL, NotificationChannel::CHANNEL_DATABASE];
    }

    return [NotificationChannel::CHANNEL_MAIL];
}
Copied!

Creating a Notification 

Extend the abstract Notification class and implement the methods for each channel your notification uses:

namespace MyVendor\MyExtension\Notification;

use Lex\Notifications\Notification;
use Lex\Notifications\NotificationChannel;
use Lex\Notifications\NotificationLevel;
use TYPO3\CMS\Core\Mail\MailMessage;

final class OrderConfirmed extends Notification
{
    public function __construct(
        private readonly Order $order,
    ) {}

    /**
     * RFC 5424 severity level for this notification.
     * Optional - Only if you want to specify a notification level different from INFO
     */
    public function getLevel(): int
    {
        return NotificationLevel::LEVEL_INFO;
    }

    /**
     * Which channels to use. Receives the notifiable so you can adapt
     * the channel list per recipient type/preference.
     *
     * @return string[]
     */
    public function via(object $notifiable): array
    {
        return $notifiable->prefers_sms ? ['vonage'] : [NotificationChannel::CHANNEL_MAIL, NotificationChannel::CHANNEL_DATABASE];
    }

    /**
     * Optional - Payload for the email channel.
     * Build your own MailMessage if you want.
     */
    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage())
            ->subject('Order #' . $this->order->getNumber() . ' confirmed')
            ->html('<p>Thank you! Your order is being processed.</p>')
            ->to($notifiable->getEmail());
    }

    /**
     * Optional - Payload for the database channel.
     * Returned array is JSON-encoded and stored as-is.
     *
     * @return array<string, mixed>
     */
    public function toDatabase(object $notifiable): array
    {
        return [
            'level'    => $this->getLevel(),
            'subject'  => 'Order #' . $this->order->getNumber() . ' confirmed',
            'message'  => 'Your order has been received and is being processed.',
            'order_id' => $this->order->getUid(),
        ];
    }

    /**
     * Custom channel - Payload for the Slack channel.
     */
    public function toSlack(object $notifiable): SlackMessage
    {
        return (new SlackMessage)
            ->...
            ->...
            ->...;
    }
}
Copied!

Only implement the channel methods you actually use. If your notification only sends email, there is no need for toDatabase().

Queuing Notifications 

Implement the ShouldQueue marker interface to have your notification dispatched asynchronously via Symfony Messenger:

use Illuminate\Contracts\Queue\ShouldQueue;

final class OrderConfirmed extends Notification implements ShouldQueue
{
    // No extra methods needed — the interface is a marker only.
}
Copied!

When ShouldQueue is implemented, calling notify() wraps the notification in a NotificationQueued Messenger message. A CLI worker must be running to process the queue:

vendor/bin/typo3 messenger:consume async --time-limit=3600
Copied!

Call notifyNow() or sendNow() to bypass the queue and deliver immediately regardless of ShouldQueue.

Using the Dispatcher Directly 

Inject NotificationDispatcherInterface into any service, controller, or plugin. This is the recommended approach when you do not have a direct reference to a notifiable object, or when you need to send to multiple recipients:

use Lex\Notifications\NotificationDispatcherInterface;

final class OrderService
{
    public function __construct(
        private readonly NotificationDispatcherInterface $notifications,
        private readonly FrontendUserRepository $userRepository,
    ) {}

    public function completeOrder(Order $order): void
    {
        $buyer = $this->userRepository->findByUid($order->getBuyerUid());

        // Dispatches via Messenger queue if ShouldQueue is implemented
        $this->notifications->send($buyer, new OrderConfirmed($order));

        // Forces immediate delivery
        $this->notifications->sendNow($buyer, new OrderConfirmed($order));
    }
}
Copied!

Send to a batch of recipients in one call:

$subscribers = $this->frontendUserRepository->findByNewsletterGroup($groupId);

$this->notifications->send(
    $subscribers->toArray(),
    new MonthlyNewsletter($content),
);
Copied!

The dispatcher iterates each notifiable independently, so a failed delivery for one recipient does not block the others.

You can also target a single specific channel for one call using channel():

// Deliver only via the database channel, regardless of what via() returns
$this->notificationDispatcher->channel(NotificationChannel::CHANNEL_DATABASE)
    ->send($user, new InvoicePaid($invoice));
Copied!

Practical Use Cases 

Workflow approval alert to a backend user

// In a DataHandler hook or custom service
$responsible = new class('editor@example.com') {
    use \Lex\Notifications\Domain\Model\Ability\Notifiable;
    use \Lex\Notifications\Domain\Model\Ability\HasRouteNotificationForMail;
    public function __construct(protected readonly string $email) {}
    public function getEmail(): string { return $this->email; }
    public function getFirstName(): ?string { return null; }
    public function getLastName(): ?string { return null; }
};
$this->notifications->sendNow(
    $responsible,
    new ContentPendingReview($pageUid, $submitter),
);
Copied!

Frontend user triggers a notification to another frontend user

// In a frontend plugin action (e.g. a messaging feature)
$sender   = $this->notifiableFrontendUserRepository->findByUid($senderUid);
$receiver = $this->notifiableFrontendUserRepository->findByUid($receiverUid);

$receiver->notify(new NewMessageReceived($sender, $messageText));
Copied!

Extension notifies multiple channels for different severity levels

final class PaymentFailed extends Notification
{
    public function via(object $notifiable): array
    {
        // Critical failures go to mail + database + Slack
        return [
            NotificationChannel::CHANNEL_MAIL,
            NotificationChannel::CHANNEL_DATABASE,
            'slack',
        ];
    }
}
Copied!

Sending to a plain email without any domain model

$contact = new class('customer@example.com') {
    use \Lex\Notifications\Domain\Model\Ability\Notifiable;
    use \Lex\Notifications\Domain\Model\Ability\HasRouteNotificationForMail;
    public function __construct(protected readonly string $email) {}
};

$contact->notifyNow(new OrderReceiptEmail($order));
Copied!

Reading Database Notifications 

Inject DatabaseNotificationRepository to query stored notifications for the currently logged-in frontend user:

use Lex\Notifications\Domain\Repository\DatabaseNotificationRepository;

class NotificationController extends ActionController
{
    public function __construct(
        private readonly DatabaseNotificationRepository $notificationRepository,
    ) {}

    public function indexAction(): ResponseInterface
    {
        $uid = $this->getContext()->getAspect('frontend.user')->get('id');

        $this->view->assign(
            'notifications',
            $this->notificationRepository->findByNotifiable($uid),
        );
        return $this->htmlResponse();
    }

    public function markAllReadAction(): ResponseInterface
    {
        $uid = $this->getContext()->getAspect('frontend.user')->get('id');
        $this->notificationRepository->markAllAsReadForNotifiable($uid);
        return $this->redirect('index');
    }
}
Copied!

Available repository methods:

Method

Description

findByNotifiable(int $uid)

Returns all notifications for a notifiable UID, ordered by creation date descending.

markAllAsReadForNotifiable(int $uid)

Sets read_at to the current timestamp for every unread notification belonging to that UID.

removeAllForNotifiable(int $uid)

Permanently deletes all notifications for that UID.

DatabaseNotification Model 

Each stored notification exposes:

Getter

Type

Description

getType()

string

Fully-qualified notification class name.

getLevel()

int

RFC 5424 severity level.

getData()

string

Raw JSON payload as stored by toDatabase().

getDataAsArray()

array

Decoded payload as a PHP array.

getReadAt()

\DateTime|null

Read timestamp, or null if unread.

getCreatedAt()

\DateTime

Creation timestamp.

getDiffCreatedAtForHumans()

string

Human-readable relative time (e.g. "3 minutes ago").

markAsRead()

void

Sets readAt to the current date/time.

Adding Custom Channels 

Step 1 — implement the interface

namespace MyVendor\MyExtension\Notification\Channel;

use Lex\Notifications\Channel\ChannelInterface;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Lex\Notifications\Notification;

#[AutoconfigureTag('notifications.channel')]
final class SlackChannel implements ChannelInterface
{
    public function __construct(
        private readonly SlackClient $slack,
    ) {}

    /**
     * Optional. Provides a short string key used in via().
     * When omitted, the fully-qualified class name is used as the key.
     */
    public function getName(): string
    {
        return 'slack';
    }

    public function send(object $notifiable, Notification $notification): void
    {
        $payload = $notification->toSlack($notifiable);
        $this->slack->post($notifiable->getSlackWebhookUrl(), $payload);
    }
}
Copied!

Step 2 — return the key from via()

public function via(object $notifiable): array
{
    return ['slack', NotificationChannel::CHANNEL_MAIL];
}
Copied!

The NotificationManager resolves the channel by its key at send time. If getName() is not defined, use the fully-qualified class name as the key in via():

public function via(object $notifiable): array
{
    return [MyVendor\MyExtension\Notification\Channel\SlackChannel::class];
}
Copied!

Channel key lookup order:

  1. getName() — if the method exists on the channel class.
  2. Fully-qualified class name — as a fallback.

To ensure a custom channel is properly detected and registered into the manager's iterator, you must assign the notifications.channel tag to your class.

You can achieve this in one of three ways:

Option A: Declaration in Services.yaml

Add your concrete channel class manually with the required tag in your extension's Configuration/Services.yaml:

services:
    Lex\Notifications\MicrosoftTeams\Channel\TeamsChannel:
        tags: ['notifications.channel']
Copied!

Option B: Using #[AutoconfigureTag] in the channel class (Recommended)

Keep your YAML file clean by adding the Symfony attribute directly above your class definition:

use Lex\Notifications\Channel\ChannelInterface;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('notifications.channel')]
final class TeamsChannel implements ChannelInterface
{
    // ... Your channel logic
}
Copied!

Option C: Using #[Autoconfigure] in the channel class

If you need to change other service options (like visibility) while registering the tag, you can pass the tags directly into the #[Autoconfigure] attribute:

use Lex\Notifications\Channel\ChannelInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;

#[Autoconfigure(public: true, tags: ['notifications.channel'])]
final class TeamsChannel implements ChannelInterface
{
    // ... Your channel logic
}
Copied!

Customising Email Templates 

Email templates live in:

Resources/Private/
├── Layouts/Email/
│   ├── NotificationLayout.html   # HTML wrapper
│   └── NotificationLayout.txt    # Plain-text wrapper
└── Templates/Email/
    ├── BackendUserSentMessageToFrontendUser.html
    └── BackendUserSentMessageToFrontendUser.txt
Copied!

Override them in your site package by adjusting the Fluid template paths. The built-in templates receive these variables:

Variable

Type

Description

{level}

int

Notification severity level.

{subject}

string

Message subject/title.

{message}

string

Message body (may contain HTML).

{link}

string|null

Optional call-to-action URL or typolink string.

For your own Notification subclasses you can use a completely different template — simply return the rendered HTML from your toMail() method using whatever rendering approach fits your extension.