---
title: "Developer Reference"
manual: "Laravel-style notification system for TYPO3"
version: "1.4"
source: "Developer/Index.rst"
modified: "2026-09-14T21:25:06+00:00"
---

> [!TIP]
> Use the table of contents in the left panel to navigate.

# Developer Reference

-   [The Notifiable Trait](#the-notifiable-trait)
-   [Making a Model Notifiable](#making-a-model-notifiable-1)
-   [Who Can Be a Recipient?](#who-can-be-a-recipient)
-   [Creating a Notification](#creating-a-notification-1)
-   [Queuing Notifications](#queuing-notifications-1)
-   [Using the Dispatcher Directly](#using-the-dispatcher-directly-1)
-   [Practical Use Cases](#practical-use-cases-1)
-   [Reading Database Notifications](#reading-database-notifications-1)
-   [DatabaseNotification Model](#databasenotification-model)
-   [Adding Custom Channels](#adding-custom-channels)
-   [Customising Email Templates](#customising-email-templates)

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

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

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:

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

The `Notifiable` trait exposes two methods:

```php
// 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']);
```

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

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

**Extension notifies a backend user**

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

```php
// 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));
```

**Inline / anonymous notifiable (no database record needed)**

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

```php
$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));
```

**Multiple recipients of different types in one call**

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

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

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

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

```php
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];
}
```

## Creating a Notification

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

```php
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)
            ->...
            ->...
            ->...;
    }
}
```

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:

```php
use Illuminate\Contracts\Queue\ShouldQueue;

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

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

```bash
vendor/bin/typo3 messenger:consume async --time-limit=3600
```

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:

```php
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));
    }
}
```

Send to a batch of recipients in one call:

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

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

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

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

## Practical Use Cases

**Workflow approval alert to a backend user**

```php
// 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),
);
```

**Frontend user triggers a notification to another frontend user**

```php
// 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));
```

**Extension notifies multiple channels for different severity levels**

```php
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',
        ];
    }
}
```

**Sending to a plain email without any domain model**

```php
$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));
```

## Reading Database Notifications

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

```php
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');
    }
}
```

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**

```php
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);
    }
}
```

**Step 2 — return the key from** `via()`

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

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

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

> [!NOTE]
> Dependency injection works normally. Declare constructor arguments as
> usual and they will be autowired by the Symfony DI container.

Channel key lookup order:

1.  `getName()` — if the method exists on the channel class.
1.  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`:

```yaml
services:
    Lex\Notifications\MicrosoftTeams\Channel\TeamsChannel:
        tags: ['notifications.channel']
```

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

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

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

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

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

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

## Customising Email Templates

Email templates live in:

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

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.
