Building a Laravel Notification Channel for WhatsApp with TextMeFlow
Laravel's notification system already handles mail, Slack and SMS through a clean via() / toMail() pattern. There's no official WhatsApp driver, but since TextMeFlow's API is a single authenticated POST request, wiring up a custom channel takes about twenty lines of code — and you get queueing, retries and failure handling for free from Laravel's existing notification pipeline.
This guide builds a WhatsAppChannel you can attach to any notification class, plus a webhook endpoint to receive delivery status and replies.
Why a custom channel instead of calling the API inline
You could call TextMeFlow's endpoint directly from a controller with Http::post(...), and for a one-off script that's fine. But routing it through a notification channel means:
- Every notifiable model (
User,Order,Booking, …) can send WhatsApp messages via$model->notify(new OrderShipped($order)), the same way it already sends mail. - Notifications queue automatically if the notification class implements
ShouldQueue— no extra job class to write. - Failed sends go through Laravel's normal
failed()handling and retry/backoff config instead of custom try/catch scattered across the codebase.
1. The channel class
<?php
namespace App\Notifications\Channels;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Http;
class WhatsAppChannel
{
public function send(object $notifiable, Notification $notification): void
{
if (! method_exists($notification, 'toWhatsApp')) {
return;
}
$message = $notification->toWhatsApp($notifiable);
$response = Http::withToken(config('services.textmeflow.key'))
->post('https://api.textmeflow.eu/v1/messages', [
'to' => $notifiable->routeNotificationFor('whatsapp'),
'text' => $message['text'],
'media_url' => $message['media_url'] ?? null,
]);
$response->throw();
}
}
->throw() turns a non-2xx response into an exception, which is what lets Laravel's queue retry logic and failed() callback kick in for transient failures (a 429 from hitting your plan's rate limit, for example).
2. Register the channel
Add your API key to config/services.php:
'textmeflow' => [
'key' => env('TEXTMEFLOW_API_KEY'),
],
Then on the notifiable model (or a trait shared across them), tell Laravel which number to send to:
public function routeNotificationForWhatsApp(): string
{
return $this->phone_e164; // must be E.164, e.g. +32472932208
}
TextMeFlow rejects anything that isn't E.164-formatted with a 422, so validate or normalize phone numbers on input rather than at send time.
3. The notification class
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use App\Notifications\Channels\WhatsAppChannel;
class OrderShipped extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private readonly string $orderNumber) {}
public function via(object $notifiable): array
{
return [WhatsAppChannel::class];
}
public function toWhatsApp(object $notifiable): array
{
return [
'text' => "Your order #{$this->orderNumber} has shipped.",
];
}
}
Fire it exactly like any other notification:
$order->customer->notify(new OrderShipped($order->number));
Because ShouldQueue is implemented, this dispatches to your notifications queue worker instead of blocking the request — worth doing for anything triggered from a web request rather than a console command.
4. Receiving delivery status and replies
Sending is only half the integration. TextMeFlow POSTs inbound messages and delivery status updates to a webhook URL you configure in the portal, signed with HMAC so you can verify the payload came from TextMeFlow and not a spoofed request:
// routes/web.php
Route::post('/webhooks/textmeflow', WhatsAppWebhookController::class);
public function __invoke(Request $request)
{
$signature = $request->header('X-TextMeFlow-Signature');
$expected = hash_hmac('sha256', $request->getContent(), config('services.textmeflow.webhook_secret'));
abort_unless(hash_equals($expected, $signature), 401);
$payload = $request->json()->all();
match ($payload['event']) {
'message.delivered' => $this->markDelivered($payload),
'message.received' => $this->handleReply($payload),
default => null,
};
return response()->noContent();
}
Full header names and event payload shapes are documented at /docs/webhooks — worth reading before you write the signature check, since getting the raw-body-vs-parsed-body order wrong is the most common bug here (always HMAC the raw request body, not json_encode($request->all()), which can re-serialize differently).
Testing without spending your quota
TextMeFlow's free plan gives you 50 messages/month for the lifetime of the account, which is enough to build and test this end-to-end without upgrading. Point TEXTMEFLOW_API_KEY at a sandbox account in your .env.testing, or fake the HTTP client in your notification tests with Http::fake() so your CI suite doesn't burn real quota on every run.
Wrapping up
A thin channel class is all it takes to make WhatsApp a first-class citizen alongside mail and Slack in a Laravel app — same notify() call, same queue infrastructure, same retry semantics. For the full endpoint reference (media attachments, message status polling, rate limits) see /docs/api. Create a free TextMeFlow account to get an API key and start sending.
Zelf WhatsApp-berichten versturen via API?
Gratis voor altijd tot 50 berichten/maand. QR scannen en binnen 5 minuten verstuur je je eerste bericht.
Gratis voor altijd