DEV Community

Cover image for A Local WhatsApp Gateway for Laravel Notifications: Architecture, Trade-offs and Honest Limits
Web Pioneer
Web Pioneer

Posted on • Originally published at web-pioneer.com

A Local WhatsApp Gateway for Laravel Notifications: Architecture, Trade-offs and Honest Limits

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

Read this before you build anything. There are two fundamentally different ways to send WhatsApp messages programmatically. The official WhatsApp Business Cloud API is a supported product with documented rate limits, template approval, and an account you can be held accountable to. Community libraries that drive the WhatsApp Web protocol from a linked device are unofficial. They are not covered by WhatsApp's terms for business messaging, the number can be restricted or banned, and there is no support path when that happens.

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;
}
Enter fullscreen mode Exit fullscreen mode

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'),
        };
    });
}
Enter fullscreen mode Exit fullscreen mode

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.

Design rule: the default in a fresh .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
    }
}
Enter fullscreen mode Exit fullscreen mode

Call sites now look like this, and stay this way forever regardless of provider:

$booking->customer->notify(new BookingConfirmed($booking));
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.target
Enter fullscreen mode Exit fullscreen mode

StartLimitBurst 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', '');
    }
}
Enter fullscreen mode Exit fullscreen mode

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];
    }
}
Enter fullscreen mode Exit fullscreen mode
// AppServiceProvider::boot()
RateLimiter::for('whatsapp', fn () => Limit::perMinute(20));
Enter fullscreen mode Exit fullscreen mode

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)
    );
}
Enter fullscreen mode Exit fullscreen mode

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"/>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Implement CloudApiTransport against the same interface.
  2. 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.
  3. 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.

FAQ

Is it safe to use an unofficial WhatsApp library in production?

For customer-facing business messaging, no. Libraries that drive the WhatsApp Web protocol from a linked device fall outside WhatsApp's business messaging terms, and the number can be restricted or banned with no support path. They are reasonable for development, integration testing, and internal notifications to your own team's numbers with consent. Production customer messaging belongs on the official WhatsApp Business Cloud API.

Why route WhatsApp through Laravel's notification system instead of calling an API directly?

Because it keeps the provider decision reversible. A notification declares what to send and through which channel; the transport underneath is bound at runtime from configuration. When pricing, terms, or provider availability change, you implement one new class and flip an environment variable instead of editing every call site in the application.

How do I stop my test suite from sending real messages?

Bind a null transport as the default and force it in phpunit.xml with force="true" on the environment variable. Without that attribute an existing environment variable takes precedence and a developer machine or CI runner with production values configured will send real messages during tests. Use Notification::fake() in feature tests so assertions run without any transport being invoked at all.

What rate should I send WhatsApp messages at?

Lower than you think, and ramped gradually. Sudden bursts from a new sender look like abuse and the penalty lands on your number. Put a queue rate limiter in front of the channel, start conservatively, warm the sender up over days rather than launching at full volume, and make the limit a configuration value so you can lower it instantly during an incident.

What should a messaging gateway never do?

It should never bind to a public interface, never log message bodies or unmasked recipient numbers, never run without a supervisor restart limit, and never be called synchronously from a web request. The first exposes credentials equivalent to a logged-in phone, the second creates a high-sensitivity data leak, the third produces restart loops that get numbers flagged, and the fourth lets a hung gateway exhaust your workers.


Originally published at web-pioneer.com.

Top comments (0)