> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom notification channels and delivery testing for packages

> Learn how to ship Laravel 13 notification channels from a package, covering resolution by class name, destination routing, queueing, and testing real delivery.

When you want a package to add a new way to deliver notifications, design **channel selection** and **destination resolution** as separate concerns. This page uses a small channel that records operational events to the log as an example, and explains how classes shipped in a package are resolved and what you need to verify when you update them. If you replace the log with delivery to an external service, the division of responsibilities between the channel and the notification stays the same.

This page is based on the Laravel Framework `v13.38.0` implementation. For basic notification usage, see [Notifications](/en/notifications.md). For setting up a package test environment, see [Package testing](/en/advanced/package-testing.md).

## The class name is the delivery method, the route is the destination

The channel class name returned from `via()` determines which object's `send()` method is called. The value retrieved through `routeNotificationFor()`, on the other hand, is the destination that the channel uses. The two don't need to share the same name.

| Where it's specified | Example on this page | Role |
| - | - | - |
| The notification's `via()` | `OperationsLogChannel::class` | Resolves the channel and delivers |
| The notifiable's route | `operationsLog` → `operations` | Destination used inside the channel |
| The notification's `viaQueues()` | `OperationsLogChannel::class` → `operations-log` | Where the job goes when queued |

`NotificationSender` passes the channel name chosen by `via()` to `ChannelManager::driver()`. If `ChannelManager` can't find the name among built-in or registered names and the name refers to an existing class, it resolves that class from the container. **If you only use the class name, you don't need `Notification::extend()` or a package service provider.** Just make sure Composer's autoloader can find the class and the container can resolve its constructor dependencies.

```mermaid theme={null}
flowchart LR
    A["Select class name in via()"] --> B["ChannelManager::driver()"]
    B --> C{"Already resolved?"}
    C -->|Yes| D["Retained channel"]
    C -->|No| E["Create via container"]
    E --> D
    D --> F["send() gets destination and delivers"]
```

## Ship the channel itself in your package

The example `Acme\OperationsLog` package records event names that contain no sensitive information, together with the name of the operations team being notified. In a Laravel application, `Psr\Log\LoggerInterface` is bound to the logging service, so you can receive it through the constructor.

```php theme={null}
<?php

namespace Acme\OperationsLog;

use Illuminate\Notifications\Notification;
use InvalidArgumentException;
use Psr\Log\LoggerInterface;

final class OperationsLogChannel
{
    public function __construct(private readonly LoggerInterface $logger)
    {
    }

    public function send(object $notifiable, Notification $notification): void
    {
        $destination = $notifiable->routeNotificationFor('operationsLog', $notification);

        if (! is_string($destination) || $destination === '') {
            throw new InvalidArgumentException('Operations log destination is required.');
        }

        $this->logger->info('Operations notification', [
            'destination' => $destination,
            'event' => $notification->toOperationsLog($notifiable),
        ]);
    }
}
```

The channel expects to receive either a model that uses the `Notifiable` trait or an on-demand notifiable created by `Notification::route()`. For the former, the value comes from `routeNotificationForOperationsLog()`; for the latter, it's the value registered with `route('operationsLog', ...)`. This example throws an exception so that a notification without a destination is never treated as delivered. When you replace this with real external delivery, check the destination and the send result as well, and design the channel so that failures are never swallowed and reported as success.

> [!WARNING]
>
> Don't include access tokens or personal information in the event names or destinations you write to the log. This example sends to the log only to demonstrate channel resolution and testing. It isn't a delivery implementation for an external service.

## Define the notification and destination in the consuming application

The application's notification returns the channel class and defines only the message passed to the channel. When you use `ShouldQueue` and `Queueable`, a regular `notify()` call goes through the queue.

```php theme={null}
<?php

namespace App\Notifications;

use Acme\OperationsLog\OperationsLogChannel;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;

class HealthCheckPassed extends Notification implements ShouldQueue
{
    use Queueable;

    public function via(object $notifiable): array
    {
        return [OperationsLogChannel::class];
    }

    public function toOperationsLog(object $notifiable): string
    {
        return 'health-check-passed';
    }

    public function viaQueues(): array
    {
        return [OperationsLogChannel::class => 'operations-log'];
    }
}
```

When you specify the destination on the fly, the key passed to `Notification::route()` is **not the channel class name**. It's the key the channel uses when it calls `routeNotificationFor('operationsLog', ...)`.

```php theme={null}
use App\Notifications\HealthCheckPassed;
use Illuminate\Support\Facades\Notification;

Notification::route('operationsLog', 'operations')
    ->notify(new HealthCheckPassed());
```

To send from an application model, add the following method to a model that uses `Notifiable`.

```php theme={null}
use Illuminate\Notifications\Notification;

public function routeNotificationForOperationsLog(Notification $notification): string
{
    return 'operations';
}
```

Calling `$user->notify(new HealthCheckPassed())` on this model selects the same channel. Configure a queue connection and start a worker that processes the `operations-log` queue.

```shell theme={null}
php artisan queue:work --queue=operations-log
```

The Manager retains one `OperationsLogChannel` instance per channel, so **don't store the destination or notification content in channel properties**. Read them from the arguments of each `send()` call instead. This keeps long-running queue workers from carrying information over from a previous notification.

## When you need a short alias

Only if you want users to specify a short name in `via()` instead of the class name, use `Notification::extend()` in your package's regular service provider.

```php theme={null}
<?php

namespace Acme\OperationsLog;

use Illuminate\Support\Facades\Notification;
use Illuminate\Support\ServiceProvider;

class OperationsLogServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Notification::extend(
            'acme-operations-log',
            fn ($app) => $app->make(OperationsLogChannel::class),
        );
    }
}
```

Make sure the provider is loaded before any delivery starts, for example through [package discovery](/en/advanced/package-discovery.md). In notifications that use the alias, change `via()` to `['acme-operations-log']` and change the `viaQueues()` key to the same name. **The `operationsLog` key used to retrieve the destination is a separate key**, so you don't need to change it. Choose a name that doesn't collide with built-in channel names.

The Manager retains resolved channels by name. Re-registering the alias's creator within the same process doesn't replace an instance that has already been created. When you change how the channel is registered, also verify the change in a freshly booted application and worker.

## Test delivery separately from fake

`Notification::fake()` verifies the **intent** to send a notification. It can't confirm that the channel's `send()` runs or that the route is retrieved. To test delivery, use the real notification Manager and replace `LoggerInterface` with a test double.

The following example runs from [Testbench's `TestCase`](/en/advanced/package-testing). With `notifyNow()`, the channel logic runs without starting a worker, even for a `ShouldQueue` notification.

```php theme={null}
<?php

namespace Acme\OperationsLog\Tests;

use Acme\OperationsLog\OperationsLogChannel;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification as BaseNotification;
use Illuminate\Support\Facades\Notification;
use Orchestra\Testbench\TestCase;
use Psr\Log\LoggerInterface;

class OperationsLogChannelTest extends TestCase
{
    public function test_channel_writes_the_routed_event(): void
    {
        $notification = new class extends BaseNotification implements ShouldQueue {
            use Queueable;

            public function via(object $notifiable): array
            {
                return [OperationsLogChannel::class];
            }

            public function toOperationsLog(object $notifiable): string
            {
                return 'health-check-passed';
            }
        };

        $logger = $this->createMock(LoggerInterface::class);
        $logger->expects($this->once())->method('info')->with(
            'Operations notification',
            ['destination' => 'operations', 'event' => 'health-check-passed'],
        );

        $this->app->instance(LoggerInterface::class, $logger);

        Notification::route('operationsLog', 'operations')->notifyNow($notification);
    }
}
```

Also verify that a missing destination throws an exception and, if you provide an alias, that it resolves from a fresh boot. If the channel is used through the queue, also test in a verification application that runs a separate queue worker that jobs arrive on the `operations-log` queue and are processed.

`NotificationSender` splits a `ShouldQueue` notification into one job per channel and selects the queue using the `viaQueues()` key. If the channel's `send()` throws an exception, it dispatches `NotificationFailed` and rethrows the exception; if `send()` returns normally, it dispatches `NotificationSent`. Queue retry behavior follows the application's configuration. Note that if you swallow exceptions inside the channel, failed deliveries are treated as successful.

## Primary sources

* [Laravel 13 official documentation: Custom notification channels and queueing](https://github.com/laravel/docs/blob/13.x/notifications.md#custom-channels)
* [Laravel 13 official documentation: Package discovery](https://github.com/laravel/docs/blob/13.x/packages.md#package-discovery)
* [ChannelManager: Resolution from class names](https://github.com/laravel/framework/blob/v13.38.0/src/Illuminate/Notifications/ChannelManager.php)
* [NotificationSender: Per-channel delivery, queueing, and events](https://github.com/laravel/framework/blob/v13.38.0/src/Illuminate/Notifications/NotificationSender.php)
* [RoutesNotifications and AnonymousNotifiable: Notification routes](https://github.com/laravel/framework/blob/v13.38.0/src/Illuminate/Notifications/RoutesNotifications.php)
* [Manager: Driver retention and extension](https://github.com/laravel/framework/blob/v13.38.0/src/Illuminate/Support/Manager.php)
* [Application: LoggerInterface binding](https://github.com/laravel/framework/blob/v13.38.0/src/Illuminate/Foundation/Application.php)


## Related topics

- [Notifications](/en/notifications.md)
- [Notification channel - Laravel Bluesky](/en/packages/laravel-bluesky/notification.md)
- [Testing](/en/packages/laravel-bluesky/testing.md)
- [Laravel Console Starter](/en/packages/laravel-console-starter/index.md)
- [Mail](/en/mail.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.