marko/notification
Notification system contracts and channels --- send notifications to users via mail, database, or custom channels from a single API. This package provides the core notification infrastructure: NotificationInterface for defining notifications, NotifiableInterface for entities that receive them, ChannelInterface for delivery mechanisms, and NotificationSender for dispatching. Built-in channels include mail and database. Notifications can also be queued for background delivery when marko/queue is installed.
Installation
Section titled “Installation”composer require marko/notificationDefining a Notification
Section titled “Defining a Notification”Implement NotificationInterface to define what channels to use and how to format the notification 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, ]; }}Making an Entity Notifiable
Section titled “Making an Entity Notifiable”Implement NotifiableInterface on any entity that should receive notifications:
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; }}Sending Notifications
Section titled “Sending Notifications”Inject NotificationSenderInterface and dispatch:
use Marko\Notification\Contracts\NotificationSenderInterface;
class OrderService{ public function __construct( private NotificationSenderInterface $notificationSender, ) {}
public function shipOrder( User $user, string $trackingNumber, ): void { $this->notificationSender->send( $user, new OrderShippedNotification($trackingNumber), ); }}Type against the interface, not the concrete NotificationSender: the module binds NotificationSenderInterface to NotificationSender, and only code typed against the interface can be swapped for FakeNotificationSender in tests. Code typed against the concrete class still works but always gets the real sender.
Sending to Multiple Recipients
Section titled “Sending to Multiple Recipients”Pass an array of notifiables:
$this->notificationSender->send( [$user1, $user2], new OrderShippedNotification($trackingNumber),);Queuing Notifications
Section titled “Queuing Notifications”Queue for background delivery (requires marko/queue):
$this->notificationSender->queue( $user, new OrderShippedNotification($trackingNumber),);queue() pushes a SendNotificationJob onto the queue. The job implements ContainerAwareJobInterface and resolves the concrete NotificationSender (never whatever is bound to NotificationSenderInterface) from the container at handle-time, so a queued notification is always delivered for real and no services are serialized into the payload. The worker releases the container once handle() returns or throws, so a job that fails for the last time is still recorded in the failed-job store. If no queue implementation is available, calling queue() throws a NotificationException with a suggestion to install a queue driver.
Channel Resolution
Section titled “Channel Resolution”ChannelResolver::resolve() maps each channel name to the notifiables whose channels() declared it. NotificationSender uses it to deliver; FakeNotificationSender uses it to record the channels a real send would use. Use it yourself when you need the same mapping:
use Marko\Notification\ChannelResolver;
$byChannel = new ChannelResolver()->resolve([$user1, $user2], $notification);// ['mail' => [$user1], 'database' => [$user1, $user2]]Registering Custom Channels
Section titled “Registering Custom Channels”Register channels via NotificationManager:
use Marko\Notification\NotificationManager;
$notificationManager->register('sms', new SmsChannel($smsClient));Customization
Section titled “Customization”Bind your own NotificationSenderInterface implementation, or replace NotificationSender via Preference to add custom behavior like logging or rate limiting:
use Marko\Core\Attributes\Preference;use Marko\Notification\NotificationSender;use Marko\Notification\Contracts\NotifiableInterface;use Marko\Notification\Contracts\NotificationInterface;
#[Preference(replaces: NotificationSender::class)]class LoggingNotificationSender extends NotificationSender{ public function send( NotifiableInterface|array $notifiables, NotificationInterface $notification, ): void { // Custom pre-send logic parent::send($notifiables, $notification); }}API Reference
Section titled “API Reference”NotificationSenderInterface
Section titled “NotificationSenderInterface”Implemented by NotificationSender (the real sender) and Marko\Testing\Fake\FakeNotificationSender.
use Marko\Notification\Contracts\NotificationSenderInterface;use Marko\Notification\Contracts\NotifiableInterface;use Marko\Notification\Contracts\NotificationInterface;
interface NotificationSenderInterface{ public function send(NotifiableInterface|array $notifiables, NotificationInterface $notification): void; public function queue(NotifiableInterface|array $notifiables, NotificationInterface $notification): void;}ChannelResolver
Section titled “ChannelResolver”use Marko\Notification\ChannelResolver;use Marko\Notification\Contracts\NotificationInterface;
public function resolve(array $notifiables, NotificationInterface $notification): array; // array<string, list<NotifiableInterface>>NotificationManager
Section titled “NotificationManager”use Marko\Notification\NotificationManager;use Marko\Notification\Contracts\ChannelInterface;
public function register(string $name, ChannelInterface $channel): void;public function channel(string $name): ChannelInterface;public function hasChannel(string $name): bool;public function getRegisteredChannels(): array;NotificationInterface
Section titled “NotificationInterface”use Marko\Mail\Message;use Marko\Notification\Contracts\NotifiableInterface;use Marko\Notification\Contracts\NotificationInterface;
interface NotificationInterface{ public function channels(NotifiableInterface $notifiable): array; public function toMail(NotifiableInterface $notifiable): Message; public function toDatabase(NotifiableInterface $notifiable): array;}NotifiableInterface
Section titled “NotifiableInterface”use Marko\Notification\Contracts\NotifiableInterface;
interface NotifiableInterface{ public function routeNotificationFor(string $channel): mixed; public function getNotifiableId(): string|int; public function getNotifiableType(): string;}ChannelInterface
Section titled “ChannelInterface”use Marko\Notification\Contracts\ChannelInterface;use Marko\Notification\Contracts\NotifiableInterface;use Marko\Notification\Contracts\NotificationInterface;
interface ChannelInterface{ public function send(NotifiableInterface $notifiable, NotificationInterface $notification): void;}BatchChannelInterface
Section titled “BatchChannelInterface”Channels may opt into batch delivery by implementing BatchChannelInterface in addition to ChannelInterface. When NotificationSender fans out a notification to more than one recipient on the same channel and that channel implements BatchChannelInterface, it calls sendMany() instead of per-recipient send() calls. For non-batch channels the fallback per-recipient path is always used.
use Marko\Notification\Contracts\BatchChannelInterface;use Marko\Notification\Contracts\ChannelInterface;use Marko\Notification\Contracts\NotifiableInterface;use Marko\Notification\Contracts\NotificationInterface;
class SmsChannel implements ChannelInterface, BatchChannelInterface{ public function send( NotifiableInterface $notifiable, NotificationInterface $notification, ): void { // single-recipient delivery }
public function sendMany( array $notifiables, NotificationInterface $notification, ): void { // bulk delivery in one operation }}The built-in DatabaseChannel implements BatchChannelInterface and persists multiple recipients via a single chunked multi-row INSERT. It stamps created_at from the injected PSR-20 ClockInterface, so a test that passes a FakeClock gets a known timestamp. The time is converted to the database timezone (database.timezone, UTC by default) before it is written, whatever timezone the clock is in.
use Marko\Notification\Contracts\BatchChannelInterface;use Marko\Notification\Contracts\NotifiableInterface;use Marko\Notification\Contracts\NotificationInterface;
interface BatchChannelInterface{ /** @param array<NotifiableInterface> $notifiables */ public function sendMany(array $notifiables, NotificationInterface $notification): void;}Exceptions
Section titled “Exceptions”| Exception | Description |
|---|---|
NotificationException | Base exception for all notification errors --- includes getContext() and getSuggestion() methods |
ChannelException | Thrown when a channel cannot route or deliver a notification |