Laravel Notification Channels, under the hood

September 2, 2026

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:

  1. A class with a send(mixed $notifiable, Notification $notification) method.
  2. A service provider with a contextual binding for the HTTP client, registered via package discovery.
  3. A toYourService() convention documented in the readme.
  4. 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).

Last Updated: September 2, 2026, 01:35 AM UTC