Laravel Notification Channels, under the hood
Laravel Notification Channels is a collection of around 60 community packages that add delivery channels to Laravel's notification system. Telegram, Pushover, WebPush, Discord, a fair few SMS providers. Most people install one, follow the readme, and never look inside. The machinery underneath is smaller than you'd expect, so it's worth a look, especially if you ever need to write your own.
# What a channel actually is
There's no interface to implement. No ChannelInterface, no abstract base class, nothing to
extend. A notification channel is any class with a send method that takes a notifiable and a
notification.
Here is the entire Pushover channel, minus the imports:
class PushoverChannel
{
public function __construct(
protected Pushover $pushover,
protected Dispatcher $events,
) {}
public function send(mixed $notifiable, Notification $notification): void
{
if (! $pushoverReceiver = $notifiable->routeNotificationFor('pushover')) {
return;
}
$message = $notification->toPushover($notifiable);
try {
$this->pushover->send(
array_merge($message->toArray(), $pushoverReceiver->toArray()),
$notifiable
);
} catch (ServiceCommunicationError $e) {
$this->fireFailedEvent($notifiable, $notification, $e->getMessage());
}
}
}
That's it. Get the destination off the notifiable, ask the notification to build a message, send it over HTTP. Every channel in the org is a variation on those three steps.
# How via() finds it
The interesting part is how via() resolves a class name into an object. When you write this:
public function via($notifiable)
{
return [PushoverChannel::class];
}
Laravel's ChannelManager extends Illuminate\Support\Manager, which resolves drivers by
convention. It takes the driver name, studly-cases it, and looks for a matching method:
mail becomes createMailDriver, database becomes createDatabaseDriver. If no such method
exists, Manager::createDriver throws an InvalidArgumentException.
ChannelManager overrides that method and catches the exception:
protected function createDriver($driver)
{
try {
return parent::createDriver($driver);
} catch (InvalidArgumentException $e) {
if (class_exists($driver)) {
return $this->container->make($driver);
}
throw $e;
}
}
So PushoverChannel::class fails the convention lookup, falls into the catch, passes
class_exists, and gets built by the container. That single fallback is the whole custom channel
system. There's no registry of channels anywhere, and nothing in the framework knows Pushover
exists.
Because it's $this->container->make(), constructor injection works normally, which is how the
channel above receives its configured Pushover client and the event dispatcher without ever
touching a facade.
# Wiring up the dependencies
The channel needs a configured API client. Packages handle this with a service provider and a contextual binding:
public function boot(): void
{
$this->app->when(PushoverChannel::class)
->needs(Pushover::class)
->give(function () {
return new Pushover(new HttpClient(), config('services.pushover.token'));
});
}
Contextual binding rather than a plain singleton, so the package doesn't claim the global
Pushover binding. If your app already binds that class for its own use, the two don't collide.
The provider is registered through package discovery, which is why installing a channel is a single composer require with no config changes:
"extra": {
"laravel": {
"providers": [
"NotificationChannels\\Pushover\\PushoverServiceProvider"
]
}
}
# Where the message comes from
$notification->toPushover($notifiable) is a convention, not a contract. Nothing enforces it.
The channel picks a method name, the readme documents it, and your notification class implements
it. That's why a notification going to three channels has three builder methods on it: toMail,
toPushover, toTelegram.
Same story for the destination. routeNotificationFor('pushover') looks for a
routeNotificationForPushover method on your notifiable, falling back to the $notifiable->email
style properties for the built-in channels. Return null from it and the channel exits early, which
is the standard way to skip delivery for a user who hasn't set that service up.
Message classes are almost always fluent builders that end in toArray. They exist to give you
autocomplete and validation over what would otherwise be an untyped payload array.
# Queueing and failure
Channels don't deal with queueing at all. If your notification implements ShouldQueue, the
framework pushes the whole send onto the queue before any channel is touched, so a channel's
send method is always running wherever the notification is being processed. You get retries and
backoff from the queue worker for free, and the channel stays a plain HTTP call.
Failure handling is less uniform than you'd hope. NotificationSender dispatches NotificationSent
after every successful channel send, and channels are expected to dispatch NotificationFailed
themselves when a send fails. Some do, like the Pushover one above. Others let the exception
bubble up to the queue worker instead, so it gets retried. Worth reading the specific package
before you rely on catching one or the other.
There's also a shouldSend hook that runs before every channel:
protected function shouldSendNotification($notifiable, $notification, $channel)
{
if (method_exists($notification, 'shouldSend') &&
$notification->shouldSend($notifiable, $channel) === false) {
return false;
}
return $this->events->until(
new NotificationSending($notifiable, $notification, $channel)
) !== false;
}
Define shouldSend on your notification, or return false from a NotificationSending listener,
and that channel is skipped while the others still fire. Handy for per-user notification
preferences, and cheaper than filtering inside via().
# Writing your own
If none of the 60 packages cover your service, the whole job is:
- A class with a
send(mixed $notifiable, Notification $notification)method. - A service provider with a contextual binding for the HTTP client, registered via package discovery.
- A
toYourService()convention documented in the readme. - A message class with a fluent API and a
toArray.
Then via() returns your class name and the container does the rest. You don't register it
anywhere.
The packages are all MIT and live at github.com/laravel-notification-channels (opens new window).