Every business application in this region eventually needs to send a WhatsApp message. Booking confirmations, OTPs, delivery updates, invoice reminders — the channel your customers actually read.
The engineering question is not "how do I send a WhatsApp message." It is "how do I keep that decision reversible." Messaging providers change, pricing changes, template approval rules change, and a project that hardcodes one provider's HTTP client across forty call sites will pay for it repeatedly.
This article describes the shape we use: WhatsApp delivery sits behind Laravel's own notification abstraction, with a swappable transport underneath, developed and tested against a small self-hosted gateway on localhost. It also states plainly where that gateway is not appropriate, because that part usually gets left out.
Start with the honest constraint
Our position: a local gateway is legitimate for development, integration testing, and internal notifications to your own team's numbers with their consent. For customer-facing production messaging, use the official Cloud API. The architecture below is designed so that switching between them is a config change, not a rewrite.
If a vendor tells you an unofficial gateway is a production-grade substitute for the Cloud API, they are transferring risk to you.
The seam: one interface, several transports
Laravel already gives you the right abstraction. A notification declares what it wants to send and through which channel; it should never know how bytes reach a phone.
Define a narrow contract:
<?php
namespace App\Messaging;
interface MessageTransport
{
/**
* @param string $to E.164 digits, no punctuation
* @param string $body Plain text
* @return string Provider message id, for correlation
* @throws TransportException
*/
public function send(string $to, string $body): string;
}Then bind whichever implementation the environment asks for:
// app/Providers/MessagingServiceProvider.php
public function register(): void
{
$this->app->bind(MessageTransport::class, function ($app) {
return match (config('messaging.driver')) {
'cloud_api' => new CloudApiTransport(config('messaging.cloud_api')),
'gateway' => new LocalGatewayTransport(config('messaging.gateway')),
'log' => new LogTransport(),
'null' => new NullTransport(),
default => throw new \InvalidArgumentException('Unknown messaging driver'),
};
});
}Four drivers, and the last two matter more than people expect. log writes the message to your application log instead of sending it — that is your local development default. null discards silently — that is your test suite default, so nothing escapes during CI.
.env.example should never be a driver that can reach a real phone. Make the safe option the one you get by forgetting to configure anything.The notification channel
Wrap the transport in a custom channel so notifications stay declarative:
<?php
namespace App\Messaging;
use Illuminate\Notifications\Notification;
class WhatsAppChannel
{
public function __construct(private MessageTransport $transport) {}
public function send($notifiable, Notification $notification): void
{
$to = $notifiable->routeNotificationFor('whatsapp');
if (! $to) {
return;
}
$message = $notification->toWhatsApp($notifiable);
$this->transport->send($this->normalise($to), $message);
}
private function normalise(string $number): string
{
return preg_replace('/\D+/', '', $number); // digits only
}
}Call sites now look like this, and stay this way forever regardless of provider:
$booking->customer->notify(new BookingConfirmed($booking));The local gateway
The gateway is a small Node service that owns a linked WhatsApp session and exposes a minimal HTTP surface. Three design decisions carry all the weight:
1. Bind to loopback only
The gateway holds credentials equivalent to a logged-in phone. It must never be reachable from the internet:
app.listen(3010, '127.0.0.1'); // not 0.0.0.0. ever.Laravel talks to it over http://127.0.0.1:3010. If your app servers are separate machines, put it behind an authenticated internal tunnel — do not open the port.
2. Persist the session outside the process
Pairing a device is a manual, interactive step. If your session state lives only in memory, every restart, deploy, or crash forces someone to re-scan a code — which means the service will be unavailable exactly when nobody is watching. Persist auth state to disk, and back that directory up like any other credential store.
3. Run it under a supervisor with restart limits
# /etc/systemd/system/wa-gateway.service
[Unit]
Description=Local WhatsApp gateway
After=network-online.target
[Service]
Type=simple
User=wagateway
WorkingDirectory=/opt/wa-gateway
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=10
StartLimitBurst=5
StartLimitIntervalSec=300
Environment=NODE_ENV=production
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/wa-gateway/session
[Install]
WantedBy=multi-user.targetStartLimitBurst matters. A gateway whose session has been invalidated will fail on every start; without a limit, systemd will restart it in a tight loop and hammer the upstream service, which is the fastest way to get a number flagged. Let it fail loudly after five attempts and alert on the unit state.
The transport implementation
<?php
namespace App\Messaging;
use Illuminate\Support\Facades\Http;
class LocalGatewayTransport implements MessageTransport
{
public function __construct(private array $config) {}
public function send(string $to, string $body): string
{
$response = Http::timeout($this->config['timeout'] ?? 10)
->retry(2, 500, throw: false)
->withToken($this->config['token'])
->post($this->config['url'] . '/send', [
'to' => $to,
'message' => $body,
]);
if ($response->failed()) {
throw new TransportException(
'Gateway rejected message: ' . $response->status() . ' ' . $response->body()
);
}
return $response->json('id', '');
}
}Note the explicit timeout. A messaging gateway that hangs will hold a queue worker hostage; without a timeout the default can be far longer than you think, and a single unreachable service can drain your entire worker pool.
Queue it, and rate-limit it
Never send from inside a web request. Notifications should implement ShouldQueue so a slow or dead gateway degrades into a delayed message rather than a timed-out HTTP response.
More importantly, throttle deliberately. Messaging platforms treat sudden bursts from a new sender as abuse signals, and the penalty lands on your number:
// app/Notifications/BookingConfirmed.php
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Middleware\RateLimited;
class BookingConfirmed extends Notification implements ShouldQueue
{
public $tries = 3;
public $backoff = [30, 120, 600]; // seconds, escalating
public function middleware(): array
{
return [new RateLimited('whatsapp')];
}
public function via($notifiable): array
{
return [WhatsAppChannel::class];
}
}// AppServiceProvider::boot()
RateLimiter::for('whatsapp', fn () => Limit::perMinute(20));Twenty per minute is a starting point, not a recommendation — tune it to your volume and warm a new sender up gradually rather than starting at full rate.
What to log, and what never to log
| Log this | Never log this |
|---|---|
| Provider message id | Session tokens or auth state |
Recipient in masked form (+2010****9705) | Full recipient numbers in plain text |
| Template or notification class name | Message bodies containing OTPs or personal data |
| Transport latency and status code | Anything you would not want in a support ticket |
Messaging logs are among the highest-sensitivity data your application produces, and they are frequently the least protected. Mask on write, not on read.
Testing without sending anything
Because the transport is an interface, tests never touch the network:
public function test_customer_is_notified_on_confirmation(): void
{
Notification::fake();
$booking = Booking::factory()->create();
$booking->confirm();
Notification::assertSentTo(
$booking->customer,
BookingConfirmed::class,
fn ($n, $channels) => in_array(WhatsAppChannel::class, $channels)
);
}And force the safe driver in your test configuration so a misconfigured environment variable can never leak a real message from CI:
<!-- phpunit.xml -->
<env name="MESSAGING_DRIVER" value="null" force="true"/>The force="true" attribute is the important part — without it, an existing environment variable wins and your test suite starts messaging real customers. We treat that flag as mandatory for every side-effecting service in phpunit.xml.
Migrating to the official API later
When the project graduates to the Cloud API, the work is contained:
- Implement
CloudApiTransportagainst the same interface. - Add template management, because the Cloud API requires pre-approved templates for business-initiated messages outside the customer service window. This is the one place the abstraction leaks — plain-text sends become template sends with parameters, so design
toWhatsApp()to return a structured object rather than a raw string if you know this migration is coming. - Flip
MESSAGING_DRIVER.
Zero changes at any call site. That is the entire return on the indirection.
Summary
- Put every messaging provider behind one narrow interface before you write the second call site.
- Default to a non-sending driver; make reaching a real phone something you opt into explicitly.
- A local gateway is a development and internal-notification tool. Customer-facing production messaging belongs on the official API.
- Bind the gateway to loopback, persist its session, supervise it with restart limits, and alert on the unit.
- Queue everything, rate-limit deliberately, time out aggressively, and mask recipients in logs.
We build and operate Laravel systems with WhatsApp, SMS, and email notification layers for businesses across Egypt and the Gulf. If you are designing this seam and want a review before it hardens into forty call sites, get in touch.
.jpg)