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));
}
};
}
}
<?php
namespace App\Messaging;
use Illuminate\Support\Facades\Http;
/** Bearer-token JSON provider for plain SMS. */
final class SmsDriver implements Driver
{
public function __construct(private array $cfg) {}
public function send(OutboundMessage $message): string
{
return Http::withToken($this->cfg['token'])->baseUrl($this->cfg['url'])
->post('/messages', ['from' => $this->cfg['sender'], 'to' => $message->to, 'text' => $message->body])
->throw()->json('id');
}
}
/** WhatsApp needs an approved template outside the 24 hour customer window. */
final class WhatsappDriver implements Driver
{
public function __construct(private array $cfg) {}
public function send(OutboundMessage $message): string
{
$payload = $message->template
? ['type' => 'template', 'template' => ['name' => $message->template, 'params' => [$message->body]]]
: ['type' => 'text', 'text' => ['body' => $message->body]];
return Http::withToken($this->cfg['token'])->baseUrl($this->cfg['url'])
->post("/{$this->cfg['phone_id']}/messages", ['to' => ltrim($message->to, '+')] + $payload)
->throw()->json('messages.0.id');
}
}
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
- Copy both files into
app/Messaging. - Add
messaging.channels.smsandmessaging.channels.whatsapp(base url, token, sender) to config. - Register
Messengeras a singleton in a service provider. - Send:
app(Messenger::class)->via('sms')->send(new OutboundMessage('+15550100', 'Your order shipped')). - Set
MESSAGING_DRIVER=loglocally 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.