Skip to content

Add A New Messaging Channel

Yaya sends messages over WhatsApp, SMS, Telegram, and reaches PWA users through a fourth channel value, all through one interface rather than per-integration branching in call sites. Adding a fifth channel means implementing that interface, registering the implementation, and — the part that is easy to skip — deciding where the new channel fits in the fixed resolution order that decides which channel to actually use for a given user. See Channel Abstraction for why this shape exists at all; this guide is about extending it.

Implement ChannelInterface

Every channel implements four methods:

interface ChannelInterface
{
    public function getType(): ChannelTypeEnum;
    public function send(User $user, string $message, array $options = []): SendResult;
    public function sendToIdentifier(string $identifier, string $message, array $options = []): SendResult;
    public function normalizeIdentifier(string $identifier): string;
}

[@channel-interface]. send() looks up the user's channel account and delegates to sendToIdentifier(); sendToIdentifier() is what actually talks to the underlying provider and is also usable for sending to a raw identifier before a User record exists (for example, an unregistered phone number). normalizeIdentifier() exists because different channels format identifiers differently — SmsChannel normalizes phone numbers through its SmsRoutingResolver, and each channel's send methods pass identifiers through this before anything provider-specific happens [@sms-channel].

If the new channel can also receive inbound messages (a webhook payload from the provider, or a bot framework callback), implement InboundChannelInterface instead, which extends ChannelInterface with one additional method:

interface InboundChannelInterface extends ChannelInterface
{
    public function parseInboundPayload(array $payload): InboundMessage;
}

[@inbound-channel-interface]. Both WhatsAppChannel and TelegramChannel implement this extended interface, since both receive user replies through a webhook/bot callback; SmsChannel implements only the base ChannelInterface, since it is outbound-only in this codebase [@sms-channel] [@telegram-channel].

send() and sendToIdentifier() both return a SendResult DTO regardless of outcome — success or failure is a value on that object (SendResult::success(...) / SendResult::failure(...)), not an exception. Callers such as SendChannelMessage branch on $result->success rather than catching exceptions for the ordinary failure path, so your implementation should catch provider exceptions internally and translate them into a SendResult::failure(...) with a useful error message, the way SmsChannel does around every provider call [@sms-channel].

Add a ChannelTypeEnum case

ChannelTypeEnum is the single source of truth for which channel types exist:

enum ChannelTypeEnum: string
{
    case WhatsApp = 'whatsapp';
    case Sms = 'sms';
    case Pwa = 'pwa';
    case Telegram = 'telegram';

    public function label(): string { /* ... */ }
    public function requiresPhoneNumber(): bool { /* ... */ }
}

[@channel-type-enum]. Add a new case here, its display label(), and update requiresPhoneNumber() if the channel is identified by something other than a phone number (Telegram and PWA return false; SMS and WhatsApp return true) [@channel-type-enum]. This enum backs the channel column on channel accounts and messages, so a channel implementation's getType() must return the corresponding case.

Register the channel

ChannelManager is a plain registry keyed by ChannelTypeEnum::value:

public function register(ChannelInterface $channel): void
{
    $this->channels[$channel->getType()->value] = $channel;
}

[@channel-manager]. It is populated once, in MessagingServiceProvider:

$this->app->singleton(ChannelManager::class, function () {
    $manager = new ChannelManager;
    $manager->register(new WhatsAppChannel);
    $manager->register($this->app->make(SmsChannel::class));
    $manager->register($this->app->make(TelegramChannel::class));
    return $manager;
});

[@messaging-provider]. Add your new channel's registration here, using $this->app->make(...) rather than new if the channel has constructor dependencies (as SmsChannel and TelegramChannel do) [@messaging-provider]. Nothing auto-discovers channel implementations — a class that implements ChannelInterface but is never registered here is simply invisible to ChannelManager::channel() and ChannelManager::has(), both of which are what the rest of the messaging pipeline calls to check availability [@channel-manager].

Decide where the channel sits in ChannelResolver's preference order

ChannelResolver::resolve() is what actually picks a channel for a given user when no specific channel is forced. It checks an explicit preferredChannel first, then falls through a hardcoded preference order:

// Preference order per spec §5.1: Telegram > WhatsApp > SMS > phone fallback.

[@channel-resolver]. Telegram is checked first because pilot users in this deployment are Telegram-only, and falling back to a phone-based channel for them would require a phone number they were never asked for [@channel-resolver]. WhatsApp is checked next, then SMS via a registered channel account, then SMS again as a last-resort fallback if the user simply has a phone_number on file even without an explicit SMS channel account [@channel-resolver].

Adding a new channel means making an explicit decision about where in this chain it belongs, and editing resolve() accordingly — this is the one place in the codebase that decides which channel a user actually gets, so a new channel that is only registered with ChannelManager but never inserted into this resolution order will only ever be reachable when a caller passes it as an explicit preferredChannel or forced_channel, never through normal automatic resolution.

Reference implementations

  • SmsChannel is the most heavily built-out example: it resolves per-tenant routing and driver selection through SmsRoutingResolver, supports a primary/fallback driver pair (including a custom bonga driver), and normalizes provider-specific success/failure response shapes into a single SendResult contract [@sms-channel].
  • TelegramChannel wraps a Nutgram bot instance, formats outgoing markdown into Telegram-flavored HTML, and — for alert messages carrying an alert_id in their metadata — attaches an inline button that opens the Telegram Mini App [@telegram-channel]. See Telegram Bot for the wider bot architecture this channel sits inside.
  • WhatsAppChannel wraps the crenspire/laravel-whatsapp package and resolves WhatsApp Business API credentials per FSP tenant.

For the job chain that calls into ChannelManager/ChannelResolver for both single sends and bulk batches, see Messaging Pipeline. If your new channel also needs to fail gracefully mid-batch, read Debug A Failed Or Silent Outbound Message for how delivery failures are logged and surfaced.