Search by

haimanresources / laravel-whatsapp

haiman4real

Drop-in WhatsApp Cloud API module for Laravel: outbound messages, signed inbound webhooks, conversation and message storage, delivery receipts, and setup diagnostics.

Package info

github.com/HaimanResourcesConsulting/laravel-whatsapp

pkg:composer/haimanresources/laravel-whatsapp

Statistics

Installs: 10

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.5.1 2026-09-08 12:35 UTC

This package is auto-updated.

Last update: 2026-09-08 12:37:22 UTC


README

Drop-in WhatsApp Cloud API module for Laravel: outbound messages, signed inbound webhooks, conversation and message storage, delivery receipts, and the setup diagnostics that turn Meta's silent misconfigurations into a clear error.

composer require haimanresources/laravel-whatsapp
php artisan migrate
use HRC\WhatsApp\Facades\WhatsApp;

WhatsApp::sendText('+234 801 234 5678', 'Your order has shipped.');

Requirements

PHP 8.2+, Laravel 11 or 12.

Configuration

php artisan vendor:publish --tag=whatsapp-config

Migrations run from the package, so php artisan migrate works immediately. Publish them only to change the schema:

php artisan vendor:publish --tag=whatsapp-migrations
WHATSAPP_VERIFY_TOKEN=        # you invent it; type the same value into Meta
WHATSAPP_APP_ID=              # App settings → Basic (optional, for diagnostics)
WHATSAPP_APP_SECRET=          # App settings → Basic — signs INBOUND webhooks
WHATSAPP_ACCESS_TOKEN=        # system user token — authorises OUTBOUND sends
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_BUSINESS_ACCOUNT_ID=

The app secret and the access token are not interchangeable. The token signs nothing and the secret sends nothing. Using the token where the secret belongs produces the most common failure in this integration: sending works perfectly and replies never arrive, because every inbound delivery fails signature verification and is rejected with a 401.

Setup, in the order that works

Getting WhatsApp delivering requires two separate subscriptions. Meta's dashboard shows the first as healthy while the second is missing, and reports no error anywhere — the integration simply never receives anything.

  1. Point Meta at your callback. WhatsApp → Configuration → Callback URL is https://your-app.test/webhook/whatsapp, verify token is your WHATSAPP_VERIFY_TOKEN. Saving it triggers a GET challenge this package answers automatically.

  2. Subscribe the messages field. A green tick on the field, not just a saved URL.

  3. Subscribe the app to the WhatsApp Business Account — the step that is easy to miss, because saving the callback URL looks like it finished setup:

    php artisan whatsapp:subscribe
  4. Verify:

    php artisan route:list --name=whatsapp   # both routes registered
    php artisan whatsapp:readiness           # Meta's side agrees

    readiness reads back what Meta actually holds and exits non-zero if anything would stop inbound delivery, so it is safe to run in CI or at the end of a deploy.

Meta allows one callback URL per app. A staging deployment and production cannot share one app — whichever URL is registered wins and the other environment receives nothing. Use a separate Meta app per environment. whatsapp:readiness detects and names this case.

Sending

use HRC\WhatsApp\Facades\WhatsApp;

WhatsApp::sendText($to, 'Free-form text');                  // inside the 24h window
WhatsApp::sendTemplate($to, 'order_update', 'en_US', [      // outside it
    ['type' => 'body', 'parameters' => [['type' => 'text', 'text' => 'ORD-1234']]],
]);
WhatsApp::sendMedia($to, 'https://example.com/receipt.pdf', 'document', 'Your receipt');
WhatsApp::markAsRead($wamid);

Numbers are normalised to Meta's wa_id format, so +234 801 234 5678, 234-801-234-5678 and 2348012345678 are equivalent.

Every send is recorded before the HTTP call and updated after it, so a timeout leaves a failed row carrying Meta's own error code rather than nothing at all. A failure throws — wrap calls you do not want to interrupt a request.

Outside the 24-hour customer service window Meta rejects free-form text; only an approved template will send.

Receiving

Inbound messages and receipts are stored automatically. React to them with events:

use HRC\WhatsApp\Events\MessageReceived;

class ReplyToCustomer
{
    public function handle(MessageReceived $event): void
    {
        $event->message->text;          // 'Where is my order?'
        $event->conversation->wa_id;    // '2348012345678'
        $event->conversation->owner;    // your model, if attached
    }
}
Event Fires when
MessageReceived a customer message is stored (once per message)
MessageStatusUpdated a receipt moves a message to delivered/read/failed
ConversationStarted a number messages you for the first time

Processing inline or on the queue

By default a delivery is parsed inline, before responding to Meta. That keeps the inbox correct with no queue worker running, and a failure returns non-2xx so Meta retries rather than dropping the event. Under high volume, hand it to the queue instead:

'webhook' => [
    'queue' => true,             // or WHATSAPP_WEBHOOK_QUEUE=true
    'queue_name' => 'default',
],

The trade is explicit: queueing returns to Meta faster, but a stopped worker means inbound messages sit unprocessed. whatsapp:readiness reports the unprocessed count, and whatsapp:reprocess-webhooks drains it.

Attaching conversations to your models

A conversation optionally belongs to any model — a Client, Tenant, User, or nothing:

WhatsApp::sendText($to, 'Hello', owner: $client);

$client->id === $conversation->owner->id;

owner_id is a string column, so UUID and auto-increment keys both work. An existing owner is never silently reassigned. A second owner messaging a number already bound to another is refused with OwnerConflictException: on a shared business number that is a tenant boundary, and the customer would otherwise see two owners' messages as one conversation. Transfer the conversation deliberately, or set whatsapp.owner_conflict to ignore to log and send instead.

Since an inbound reply carries only a phone number, bind ownership yourself on ConversationStarted:

Event::listen(function (ConversationStarted $event) {
    $client = Client::where('phone', $event->conversation->wa_id)->first();
    $client && $event->conversation->update([
        'owner_type' => $client->getMorphClass(),
        'owner_id' => $client->getKey(),
    ]);
});

The inbox

A complete Blade interface ships with the package at /whatsapp — no build step, no JavaScript framework in your app:

  • conversation list with search (number, profile name, or message text), unread badges, and All / Unread / Archived filters
  • threads with WhatsApp-style delivery ticks, polling every 10 seconds so replies appear without a reload
  • reply box that disables itself outside the 24-hour window and explains why, instead of letting Meta reject the message
  • start several conversations at once from one composer
  • archive, and mark a contact opted out by hand
// config/whatsapp.php
'ui' => [
    'enabled' => true,
    'path' => 'whatsapp',
    'middleware' => ['web', 'auth', 'can:manage-whatsapp'],   // yours
],

Its routes are named whatsapp.ui.*, so they cannot collide with an inbox your application already has.


Put your own auth middleware on it. The default is ['web'] only, because the package cannot know your guard — and these pages can message your customers.

Restyle it by publishing the views:

php artisan vendor:publish --tag=whatsapp-views

Tailwind and Alpine load from a CDN so the package works in any app; vendor those two files if your deployment forbids external assets.

Using it from TypeScript (React, Vue, Inertia, mobile)

The Blade UI is optional. A JSON API ships alongside it, with TypeScript definitions that mirror the responses exactly:

php artisan vendor:publish --tag=whatsapp-types   # -> resources/js/whatsapp/
import { WhatsAppClient, WhatsAppApiError } from '@/whatsapp/client';
import type { Conversation, Message, Broadcast } from '@/whatsapp/types';

const whatsapp = new WhatsAppClient();               // defaults to /api/whatsapp

const { data } = await whatsapp.conversations({ unread: true });
const { data: messages } = await whatsapp.messages(id, { after: lastSeenIso });

try {
    await whatsapp.reply(id, 'On its way');
} catch (error) {
    if (error instanceof WhatsAppApiError && error.code === 'outside_service_window') {
        // Over 24 hours since their last message: only a template will send.
    }
}

await whatsapp.createBroadcast({ name: 'Promo', recipients: [...], template: 'promo', send_now: true });

Or ignore the client and use the types with your own data layer — Inertia props, React Query, Pinia. Turn the Blade UI off entirely and keep only the API:

'ui' => ['enabled' => false],
'api' => ['enabled' => true, 'path' => 'api/whatsapp', 'middleware' => ['api', 'auth:sanctum']],
Endpoint Purpose
GET /conversations list; q, unread, archived, per_page
POST /conversations start many at once; 207 when some fail
GET /conversations/{id}/messages thread; after ISO cursor for polling
POST /conversations/{id}/messages reply; 422 with a code when refused
PATCH /conversations/{id} archive / opt-out
`GET POST /broadcasts`
POST /broadcasts/{id}/send|cancel run or stop a broadcast

Broadcasts

One message to many recipients, with per-recipient delivery tracking:

use HRC\WhatsApp\Broadcaster;

$broadcast = app(Broadcaster::class)->create(
    name: 'September promo',
    recipients: ['2348011111111', '2348022222222'],
    bodyText: 'Our new prices are live.',   // reaches contacts inside the window
    template: 'promo_september',            // reaches everyone else
);

app(Broadcaster::class)->dispatch($broadcast);

Three rules are enforced by the package rather than left to the caller, because getting any of them wrong risks the WhatsApp number itself:

Rule Behaviour
Opted-out contacts are never messaged Marked skipped before anything is sent
Free-form text only reaches the 24-hour window Cold recipients without a template are skipped, not failed
Bursts lower a number's quality rating Sends are paced at 30/minute (WHATSAPP_BROADCAST_PER_MINUTE)

Recipients are normalised and de-duplicated, so a pasted list with repeats and mixed formats cannot message anyone twice. Each recipient is its own queued job, so one bad number cannot stop the run, and a recipient already reached is never sent to twice — even if the job retries or an operator presses send again. Delivery receipts flow back into per-recipient status, so the broadcast page shows sent / delivered / read / failed / skipped as they happen.

Cancel mid-flight and everyone not yet reached is skipped.

This is not a group chat. The Cloud API has no group messaging; each recipient gets an individual thread and cannot see the others.

Opt-out

An inbound STOP (or unsubscribe, cancel, …) marks the conversation opted out and broadcasts skip it from then on; START reverses it. Honouring opt-out is a WhatsApp Business policy requirement, not a nicety. Configure the keywords or disable the automatic handling in config/whatsapp.php.

Reading conversations

use HRC\WhatsApp\Models\Conversation;

Conversation::with('messages')->latest('last_message_at')->paginate(30);
Conversation::sole()->messages()->inbound()->get();

No UI ships with the package — host apps differ too much. The models are the whole API.

Commands

Command Purpose
whatsapp:readiness Check config and what Meta has registered. Non-zero exit if inbound is broken.
whatsapp:subscribe Subscribe the app to the WABA, then verify it took effect.
whatsapp:reprocess-webhooks Replay stored envelopes after an outage or parsing fix.

Reliability

  • Idempotent inbound. Events are keyed by a hash of the raw body and messages by Meta's wamid, so redeliveries (Meta retries until it gets a 2xx) cannot duplicate.
  • Monotonic receipts. sent → delivered → read. Out-of-order receipts never regress status, and a late failed after delivered is ignored.
  • Signature verification over the exact raw bytes — reserialising the body changes key order and invalidates the signature.
  • Raw envelopes retained for replay, pruned after 90 days by model:prune (configurable; null keeps forever).

Customising the webhook route

The route registers itself at webhook/whatsapp. To control it yourself:

'webhook' => ['enabled' => false],
Route::post('/custom/whatsapp', [HRC\WhatsApp\Http\Controllers\WebhookController::class, 'handle']);
Route::get('/custom/whatsapp', [HRC\WhatsApp\Http\Controllers\WebhookController::class, 'verify']);

The route is public by necessity — Meta cannot authenticate. It is protected by signature verification and a rate limiter (whatsapp-webhook, 600/min per IP, configurable). A host app defining its own limiter of that name keeps it.

Testing against the package

Http::fake(['graph.facebook.com/*' => Http::response(['messages' => [['id' => 'wamid.test']]])]);

WhatsApp::sendText('2348012345678', 'Hello');

Http::assertSent(fn ($request) => $request['type'] === 'text');

Troubleshooting

Symptom Cause
Sending works, replies never arrive Run whatsapp:readiness. Usually no app subscribed to the WABA, or the callback points at another environment.
whatsapp.webhook.signature_failed in logs WHATSAPP_APP_SECRET wrong or empty — the app secret, not the access token.
Verification fails when saving the callback WHATSAPP_VERIFY_TOKEN differs from what you typed into Meta.
(#131047) Re-engagement message Outside the 24-hour window — send a template.
Webhooks arrive, inbox empty whatsapp:reprocess-webhooks, then check processing_error.
Broadcast shows everyone skipped No template, and the recipients are outside the 24-hour window. Add an approved template.
Broadcast sends very slowly By design — WHATSAPP_BROADCAST_PER_MINUTE paces it. Raise only if your number is on a higher tier.
No whatsapp/* routes Another package or your app registered webhook/whatsapp first, or whatsapp.webhook.enabled is false. Check php artisan route:list --name=whatsapp.

Contributing / publishing

composer install
composer test

Publishing to Packagist (needs credentials this repository does not carry):

  1. Push to github.com/HaimanResourcesConsulting/laravel-whatsapp.
  2. Submit the URL at https://packagist.org/packages/submit.
  3. Enable the GitHub service hook so new tags publish automatically.
  4. Tag releases with git tag v0.1.0 && git push --tags.

License

MIT. See LICENSE.md.