DEV3 Technologies home

SMS and WhatsApp driver abstraction

Send text messages through one Messenger facade with swappable SMS and WhatsApp drivers, selected by channel name, plus a log driver for local work.

LaravelNotificationssmswhatsappdriversmessaging

Code

<?php

namespace App\Messaging;

use Closure;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;

final readonly class OutboundMessage
{
    public function __construct(public string $to, public string $body, public ?string $template = null) {}
}

interface Driver
{
    /** Returns the provider's message id. */
    public function send(OutboundMessage $message): string;
}

final class LogDriver implements Driver
{
    public function send(OutboundMessage $message): string
    {
        Log::info('message.sent', ['to' => substr($message->to, 0, 4).'***']);

        return 'log-'.uniqid();
    }
}

final class Messenger
{
    /** @var array<string, Closure(): Driver> */
    private array $factories = [];

    /** @var array<string, Driver> */
    private array $resolved = [];

    public function register(string $channel, Closure $factory): void
    {
        $this->factories[$channel] = $factory;
        unset($this->resolved[$channel]);
    }

    public function via(string $channel): object
    {
        $driver = $this->resolved[$channel] ??= ($this->factories[$channel] ?? throw new InvalidArgumentException("Unknown channel {$channel}"))();

        // Small wrapper that normalises the number before any driver sees it.
        return new class($driver) {
            public function __construct(private Driver $driver) {}

            public function send(OutboundMessage $message): string
            {
                $digits = preg_replace('/\D+/', '', $message->to);

                return $this->driver->send(new OutboundMessage('+'.$digits, $message->body, $message->template));
            }
        };
    }
}

How it works

What it solves

Texting customers means different providers for SMS and WhatsApp, each with its own endpoint, auth and payload. Campaign code should not care. It should say "send this to this number on the sms channel" and get back a provider message id it can match to later delivery receipts.

How it works

OutboundMessage is a readonly value object with the destination, body and an optional template name, which WhatsApp needs outside the 24 hour session window. The Driver contract has one method, send, returning the provider's message id.

Messenger keeps a map from channel names to driver factories. via('whatsapp') builds the matching driver once and caches it. Because the map holds closures, tests can replace a channel with Messenger::fake()-style swaps without touching the container. The class also normalises phone numbers to digits with a plus prefix before handing them over, so drivers can assume E.164.

HttpDrivers contains a bearer-token SMS driver and a WhatsApp driver that switches between a plain text body and a template payload. A LogDriver simply writes to the log, which is the default in local environments.

Adapting it

Add voice or push channels by implementing Driver and registering a closure. Move the provider URLs into config. Store the returned id on your message row.

Pitfalls

  • Sending is slow; always call the messenger from a queued job.
  • Do not log full message bodies in production, they are personal data.

How to use it

  1. Copy both files into app/Messaging.
  2. Add messaging.channels.sms and messaging.channels.whatsapp (base url, token, sender) to config.
  3. Register Messenger as a singleton in a service provider.
  4. Send: app(Messenger::class)->via('sms')->send(new OutboundMessage('+15550100', 'Your order shipped')).
  5. Set MESSAGING_DRIVER=log locally to avoid real sends.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets