Skip to content

Notifications

Marko’s notification system lets you send messages to users through multiple channels --- mail, database, or custom --- from a single, unified API. Define a notification once and deliver it to whichever channels each recipient needs. This guide covers setup, sending, database storage, custom channels, and testing.

Install the core notification package:

Terminal window
composer require marko/notification

To persist notifications in the database, also install the database storage package:

Terminal window
composer require marko/notification-database

For background delivery, install a queue driver such as marko/queue-database or marko/queue-sync.

A notification implements NotificationInterface, declaring which channels it supports and how to format the message for each:

use Marko\Mail\Message;
use Marko\Notification\Contracts\NotifiableInterface;
use Marko\Notification\Contracts\NotificationInterface;
class OrderShippedNotification implements NotificationInterface
{
public function __construct(
private string $trackingNumber,
) {}
public function channels(
NotifiableInterface $notifiable,
): array {
return ['mail', 'database'];
}
public function toMail(
NotifiableInterface $notifiable,
): Message {
return Message::create()
->subject('Your order has shipped')
->html("<p>Tracking: $this->trackingNumber</p>");
}
public function toDatabase(
NotifiableInterface $notifiable,
): array {
return [
'title' => 'Order Shipped',
'tracking_number' => $this->trackingNumber,
];
}
}

The channels() method receives the notifiable, so you can vary channels per recipient --- for example, only sending mail to users who have opted in.

Any entity that receives notifications implements NotifiableInterface:

use Marko\Notification\Contracts\NotifiableInterface;
class User implements NotifiableInterface
{
public function __construct(
private int $id,
private string $email,
) {}
public function routeNotificationFor(
string $channel,
): mixed {
return match ($channel) {
'mail' => $this->email,
default => null,
};
}
public function getNotifiableId(): string|int
{
return $this->id;
}
public function getNotifiableType(): string
{
return self::class;
}
}

The routeNotificationFor() method returns routing information for each channel --- an email address for the mail channel, or null for channels that don’t need explicit routing (like database).

Inject NotificationSender and call send():

use Marko\Notification\NotificationSender;
class OrderService
{
public function __construct(
private NotificationSender $notificationSender,
) {}
public function shipOrder(
User $user,
string $trackingNumber,
): void {
$this->notificationSender->send(
$user,
new OrderShippedNotification($trackingNumber),
);
}
}

Pass an array of notifiables to send the same notification to several users at once:

$this->notificationSender->send(
[$user1, $user2],
new OrderShippedNotification($trackingNumber),
);

Queue notifications for background processing instead of sending inline. This requires a queue driver:

$this->notificationSender->queue(
$user,
new OrderShippedNotification($trackingNumber),
);

If no queue implementation is available, queue() throws a NotificationException with a suggestion to install a queue driver.

When the database channel is used, notifications are persisted to a notifications table. The marko/notification-database package provides a repository for querying and managing them.

Inject NotificationRepositoryInterface to fetch notifications for a user:

use Marko\Notification\Database\Repository\NotificationRepositoryInterface;
class NotificationController
{
public function __construct(
private NotificationRepositoryInterface $notificationRepository,
) {}
public function index(
User $user,
): array {
return $this->notificationRepository->forNotifiable($user);
}
public function unreadCount(
User $user,
): int {
return $this->notificationRepository->unreadCount($user);
}
}

Each DatabaseNotification stores its payload as JSON. Decode it to access the original data from toDatabase():

use Marko\Notification\Database\Repository\NotificationRepositoryInterface;
$unread = $this->notificationRepository->unread($user);
foreach ($unread as $notification) {
$data = json_decode($notification->data, true);
// $data['title'], $data['tracking_number'], etc.
}

Mark individual notifications or all at once:

// Mark one notification as read
$this->notificationRepository->markAsRead($notificationId);
// Mark all notifications as read for a user
$this->notificationRepository->markAllAsRead($user);
// Delete a single notification
$this->notificationRepository->delete($notificationId);
// Delete all notifications for a user
$this->notificationRepository->deleteAll($user);

Create a custom channel by implementing ChannelInterface, then register it with NotificationManager:

use Marko\Notification\Contracts\ChannelInterface;
use Marko\Notification\Contracts\NotifiableInterface;
use Marko\Notification\Contracts\NotificationInterface;
use Marko\Notification\Exceptions\ChannelException;
class SmsChannel implements ChannelInterface
{
public function __construct(
private SmsClient $smsClient,
) {}
public function send(
NotifiableInterface $notifiable,
NotificationInterface $notification,
): void {
$phone = $notifiable->routeNotificationFor('sms');
if ($phone === null || $phone === '') {
throw ChannelException::routeMissing('sms', $notifiable->getNotifiableType());
}
// Send via your SMS provider
$this->smsClient->send($phone, $notification->toSms($notifiable));
}
}

Register the channel during module boot:

use Marko\Notification\NotificationManager;
$notificationManager->register('sms', new SmsChannel($smsClient));

Then reference 'sms' in any notification’s channels() method.

Since the notification system is built on interfaces, test notification delivery by mocking ChannelInterface and wiring a real NotificationManager:

use Marko\Notification\Contracts\ChannelInterface;
use Marko\Notification\Contracts\NotifiableInterface;
use Marko\Notification\Contracts\NotificationInterface;
use Marko\Notification\NotificationManager;
use Marko\Notification\NotificationSender;
test('it sends order shipped notification via mail', function (): void {
$notifiable = $this->createMock(NotifiableInterface::class);
$mailChannel = $this->createMock(ChannelInterface::class);
$mailChannel->expects($this->once())
->method('send')
->with($notifiable, $this->isInstanceOf(NotificationInterface::class));
$manager = new NotificationManager();
$manager->register('mail', $mailChannel);
$sender = new NotificationSender($manager);
$sender->send($notifiable, new OrderShippedNotification('TRACK-123'));
});

To test queued notifications, mock QueueInterface and verify the job is pushed:

use Marko\Notification\Job\SendNotificationJob;
use Marko\Notification\NotificationManager;
use Marko\Notification\NotificationSender;
use Marko\Queue\QueueInterface;
test('it queues notification for background delivery', function (): void {
$notifiable = $this->createMock(NotifiableInterface::class);
$queue = $this->createMock(QueueInterface::class);
$queue->expects($this->once())
->method('push')
->with($this->isInstanceOf(SendNotificationJob::class));
$manager = new NotificationManager();
$sender = new NotificationSender($manager, $queue);
$sender->queue($notifiable, new OrderShippedNotification('TRACK-456'));
});