Search by

multek / laravel-whatsapp-cloud

rodrigocoliveira

Enterprise-grade WhatsApp Cloud API integration for Laravel with multi-phone support, batch processing, and AI agent workflows.

Package info

github.com/rodrigocoliveira/laravel-whatsapp-cloud-api

pkg:composer/multek/laravel-whatsapp-cloud

Statistics

Installs: 1 840

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 1

v1.4.0 2026-09-06 20:19 UTC

README

A comprehensive Laravel package for integrating with the WhatsApp Cloud API. Supports sending and receiving messages, media handling, batch processing for AI agents, and multi-phone configurations.

Features

  • Multi-Phone Support: Manage multiple WhatsApp phone numbers with independent handlers
  • All Message Types: Text, images, videos, audio, documents, stickers, locations, contacts, interactive buttons/lists, templates
  • Batch Processing: Collect messages in time windows before processing (ideal for AI chatbots)
  • Media Handling: Automatic download and storage of media files
  • Audio Transcription: Built-in OpenAI Whisper integration for voice messages
  • Webhook Security: HMAC-SHA256 signature verification
  • Event-Driven: Observable events for all major operations
  • Queue Support: Fully async processing pipeline with configurable queues

Requirements

  • PHP 8.2+
  • Laravel 12.0+ (Laravel 11 reached end of security support in March 2026)

Installation

composer require multek/laravel-whatsapp-cloud-api

Run the installation command:

php artisan whatsapp:install

This will publish the configuration file and migrations.

Run the migrations:

php artisan migrate

Since v1.4.0 the package's migrations run automatically on php artisan migrate (loaded straight from vendor/), so future releases that add or change a migration no longer require re-publishing. If you previously ran vendor:publish --tag=whatsapp-migrations and customized your copy, call WhatsAppServiceProvider::ignoreMigrations() from your own service provider's register() method to keep using only your published copy.

Configuration

Environment Variables

Add these to your .env file:

WHATSAPP_ACCESS_TOKEN=your_meta_access_token
WHATSAPP_WEBHOOK_VERIFY_TOKEN=your_webhook_verify_token
WHATSAPP_APP_SECRET=your_app_secret

# Optional: for audio transcription
OPENAI_API_KEY=your_openai_api_key

# Optional: queue settings
WHATSAPP_QUEUE_CONNECTION=redis
WHATSAPP_QUEUE_NAME=whatsapp

Creating a Phone Configuration

Create a phone record in the database:

use Multek\LaravelWhatsAppCloud\Models\WhatsAppPhone;

WhatsAppPhone::create([
    'key' => 'support',
    'phone_id' => 'your_meta_phone_number_id',
    'phone_number' => '+5511999999999',
    'business_account_id' => 'your_waba_id',
    'access_token' => null, // Uses default from config if null
    'handler' => \App\WhatsApp\Handlers\SupportHandler::class,
    'processing_mode' => 'batch', // or 'immediate'
    'batch_window_seconds' => 3,
    'auto_download_media' => true,
    'transcription_enabled' => true,
]);

Webhook Setup

Configure your webhook URL in the Meta Developer Portal:

https://yourdomain.com/webhooks/whatsapp

The package handles both verification (GET) and incoming events (POST).

Webhook Logging

All incoming webhook payloads are automatically stored in the whatsapp_webhook_logs table for debugging and auditing purposes. This helps you:

  • Debug issues by inspecting the exact payload Meta sent
  • Audit and replay webhooks if processing fails
  • Analyze edge cases in payload structures

Configure retention in your .env:

WHATSAPP_WEBHOOK_LOG_RETENTION_DAYS=30  # Default: 30 days

To prune old logs, add this to your app/Console/Kernel.php scheduler:

use Multek\LaravelWhatsAppCloud\Models\WhatsAppWebhookLog;

protected function schedule(Schedule $schedule): void
{
    $schedule->command('model:prune', [
        '--model' => [WhatsAppWebhookLog::class],
    ])->daily();
}

Or run manually:

php artisan model:prune --model="Multek\LaravelWhatsAppCloud\Models\WhatsAppWebhookLog"

Usage

Sending Messages

use Multek\LaravelWhatsAppCloud\Facades\WhatsApp;

// Send a text message
WhatsApp::phone('support')->sendText('+5511999999999', 'Hello!');

// Send with fluent builder
WhatsApp::phone('support')
    ->to('+5511999999999')
    ->text('Hello World!')
    ->send();

// Send an image
WhatsApp::phone('support')
    ->to('+5511999999999')
    ->image('https://example.com/image.jpg')
    ->caption('Check this out!')
    ->send();

// Send a document
WhatsApp::phone('support')
    ->to('+5511999999999')
    ->document('https://example.com/file.pdf')
    ->filename('report.pdf')
    ->send();

// Send interactive buttons
WhatsApp::phone('support')
    ->to('+5511999999999')
    ->interactive()
    ->body('Please choose an option:')
    ->button('btn_yes', 'Yes')
    ->button('btn_no', 'No')
    ->send();

// Send a WhatsApp Flow (native in-conversation form)
WhatsApp::phone('support')
    ->to('+5511999999999')
    ->flow('Complete your signup', flowId: '1234567890', cta: 'Sign up')
    ->flowToken('signup-42')            // optional, a UUID is generated otherwise
    ->flowScreen('WELCOME', ['name' => 'Rodrigo'])
    ->header('Signup')
    ->footer('Takes one minute')
    ->send();

// Send a location
WhatsApp::phone('support')
    ->to('+5511999999999')
    ->location(-23.5505, -46.6333)
    ->name('Sao Paulo')
    ->address('Sao Paulo, Brazil')
    ->send();

// Send inside an existing conversation (addresses its contact and links the message to it)
WhatsApp::phone('support')
    ->conversation($conversation)
    ->text('Following up on your order')
    ->send();

// React to a message: the recipient is resolved from the stored message
// (sender of an inbound message, destination of an outbound one)
WhatsApp::phone('support')->sendReaction('wamid.HBgL...', 'πŸ‘');
WhatsApp::phone('support')->removeReaction('wamid.HBgL...');

// Reacting to a message that is not stored locally requires the recipient
WhatsApp::phone('support')->sendReaction('wamid.HBgL...', 'πŸ‘', to: '+5511999999999');

Every outbound message is linked to a WhatsAppConversation, resolved from the sending phone and the normalized recipient (created if none exists, so inbound and outbound land on the same thread), and send() fires MessageSent once the API call succeeds. queue() links the pending message the same way and fires MessageSent from the job after delivery.

Creating a Message Handler

Create a handler class that implements MessageHandlerInterface:

namespace App\WhatsApp\Handlers;

use Multek\LaravelWhatsAppCloud\Contracts\MessageHandlerInterface;
use Multek\LaravelWhatsAppCloud\DTOs\IncomingMessageContext;

class SupportHandler implements MessageHandlerInterface
{
    public function handle(IncomingMessageContext $context): void
    {

        // Check for processing errors (failed media downloads or transcriptions)
        if ($context->hasFailedMediaDownloads()) {
            $context->reply(__('whatsapp.media_download_failed'));
            return;
        }

        if ($context->hasFailedTranscriptions()) {
            $context->reply(__('whatsapp.transcription_failed'));
            return;
        }

        // Or check for any processing error generically
        if ($context->hasProcessingErrors()) {
            foreach ($context->getProcessingErrors() as $message) {
                Log::warning('Processing error', [
                    'message_id' => $message->id,
                    'error' => $message->error_message,
                ]);
            }
            $context->reply(__('whatsapp.processing_error'));
            return;
        }

        // Get text content from all messages in the batch
        $textContent = $context->getTextContent();

        // Get media files (already downloaded)
        $mediaMessages = $context->getMedia();

        // Get audio transcriptions
        $transcriptions = $context->getTranscriptions();

        // Access the conversation
        $conversation = $context->conversation;
        $contactPhone = $conversation->contact_phone;

        // Reply to the user
        $context->reply('Thanks for your message! We will get back to you soon.');

        // Or use the fluent builder for complex replies
        $context->replyWith()
            ->text('Here is your summary:')
            ->send();
    }
}

Working with Message Content

Each message type has a typed DTO accessible via getTypedContent():

use Multek\LaravelWhatsAppCloud\DTOs\MessageContent\TextContent;
use Multek\LaravelWhatsAppCloud\DTOs\MessageContent\ImageContent;
use Multek\LaravelWhatsAppCloud\DTOs\MessageContent\LocationContent;
use Multek\LaravelWhatsAppCloud\DTOs\MessageContent\InteractiveReplyContent;
use Multek\LaravelWhatsAppCloud\DTOs\MessageContent\FlowResponseContent;

foreach ($context->messages as $message) {
    $content = $message->getTypedContent();

    if ($content instanceof TextContent) {
        $text = $content->body;
    }

    if ($content instanceof ImageContent) {
        $mediaId = $content->mediaId;
        $caption = $content->caption;
        $localPath = $message->local_media_path;
    }

    if ($content instanceof LocationContent) {
        $lat = $content->latitude;
        $lng = $content->longitude;
        $name = $content->name;
    }

    if ($content instanceof InteractiveReplyContent) {
        $buttonId = $content->id;
        $buttonTitle = $content->title;
    }

    if ($content instanceof FlowResponseContent) {
        $submitted = $content->data;          // decoded response_json
        $email = $content->get('email');
        $flowToken = $content->flowToken;
    }
}

Receiving WhatsApp Flow Responses

When the user submits a Flow, WhatsApp sends an nfm_reply interactive message. The package decodes the response_json payload for you:

public function handle(IncomingMessageContext $context): void
{
    foreach ($context->getFlowResponses() as $message) {
        $data = $message->getFlowData();   // ['flow_token' => ..., 'name' => ..., ...]

        User::create([
            'name' => $data['name'],
            'email' => $data['email'],
        ]);
    }

    // Or grab the decoded payload of every flow in the batch at once
    $payloads = $context->getFlowData();
}

The flow_token you set with ->flowToken() is echoed back inside the response data, so you can correlate a submission with whatever you were collecting.

Endpoint-Backed (data_exchange) Flows

Flows whose screens call back to your server between steps need the encrypted data-exchange endpoint.

1. Generate a key pair and upload the public half to Meta:

php artisan whatsapp:flow-key generate

Store the private key in WHATSAPP_FLOW_PRIVATE_KEY (an inline PEM or a path to a key file), then:

php artisan whatsapp:flow-key upload --phone=support

2. Enable the endpoint with WHATSAPP_FLOW_ENDPOINT_ENABLED=true. It is served at webhooks/whatsapp/flow and answers 404 while disabled. Register that URL as the endpoint URI of your Flow in the WhatsApp Manager.

3. Write a handler implementing FlowHandlerInterface and point whatsapp.flows.handler at it. Health checks (ping) and client error notifications are answered by the package and never reach your handler.

use Multek\LaravelWhatsAppCloud\Contracts\FlowHandlerInterface;
use Multek\LaravelWhatsAppCloud\DTOs\Flows\FlowRequest;
use Multek\LaravelWhatsAppCloud\DTOs\Flows\FlowResponse;

class SignupFlowHandler implements FlowHandlerInterface
{
    public function handle(FlowRequest $request): FlowResponse
    {
        if ($request->screen === 'SIGNUP') {
            $user = User::create([
                'name' => $request->get('name'),
                'email' => $request->get('email'),
            ]);

            return FlowResponse::complete($request->flowToken, ['user_id' => $user->id]);
        }

        return FlowResponse::screen('SIGNUP', ['countries' => Country::pluck('name')]);
    }
}

Send the flow with ->flowDataExchange() so the first screen calls your endpoint.

How it is secured: the endpoint has no signature check β€” authenticity comes from the encryption, since only Meta can encrypt with the public key you registered. Any payload that fails to decrypt is answered with 421, which makes Meta refresh the public key. The private key is only ever read from config; per-phone keys are not supported.

Listening to Events

use Multek\LaravelWhatsAppCloud\Events\MessageReceived;
use Multek\LaravelWhatsAppCloud\Events\BatchProcessed;
use Multek\LaravelWhatsAppCloud\Events\MediaDownloaded;

// In your EventServiceProvider or using Event facade
Event::listen(MessageReceived::class, function (MessageReceived $event) {
    Log::info('New message from: ' . $event->message->from);
});

Event::listen(BatchProcessed::class, function (BatchProcessed $event) {
    Log::info('Batch processed with ' . $event->batch->messages->count() . ' messages');
});

Event::listen(MediaDownloaded::class, function (MediaDownloaded $event) {
    Log::info('Media saved to: ' . $event->message->local_media_path);
});

Available Events

Event Description
MessageReceived When a message arrives at webhook
MessageFiltered When a message type is not allowed
MessageReady When media/transcription complete
BatchReady When batch is about to be processed
BatchProcessed After handler completes
MessageSent When outbound message is sent
MessageDelivered When message is delivered
MessageRead When message is read
MessageFailed When message send fails
MediaDownloaded After media saved locally
AudioTranscribed After audio transcribed

Message Pricing

Meta reports the billing classification of every outbound message on its status webhooks, but never the amount charged. The package stores that classification on whatsapp_messages and estimates the cost from a configurable rate card.

Columns populated from the sent/delivered/read status webhook:

Column Source Example
pricing_billable pricing.billable true
pricing_model pricing.pricing_model PMP (per-message) or CBP (legacy per-conversation)
pricing_category pricing.category marketing, utility, authentication, service
pricing_type pricing.type regular, free_customer_service, free_entry_point
meta_conversation_id conversation.id Meta's conversation identifier
conversation_origin conversation.origin.type marketing, user_initiated, ...
conversation_expires_at conversation.expiration_timestamp End of the pricing window

Configure the rate card in config/whatsapp.php under pricing.rates, keyed by E.164 dial-code prefix (longest match wins, default as fallback):

'pricing' => [
    'currency' => 'USD',
    'rates' => [
        '55' => ['marketing' => 0.0625, 'utility' => 0.0080, 'authentication' => 0.0315, 'service' => 0.0],
        'default' => ['service' => 0.0],
    ],
],

Then read the estimate on any message:

$message->isBillable();     // true, false, or null if no status webhook with pricing yet
$message->estimatedCost();  // 0.0625, 0.0 for non-billable, or null when no rate is configured

// Monthly spend per category for one phone
WhatsAppMessage::where('whatsapp_phone_id', $phone->id)
    ->where('pricing_billable', true)
    ->whereBetween('sent_at', [$start, $end])
    ->get()
    ->groupBy('pricing_category')
    ->map(fn ($messages) => $messages->sum->estimatedCost());

Always verify the shipped rates against Meta's current rate card (https://developers.facebook.com/docs/whatsapp/pricing). For exact billed amounts use the WABA pricing_analytics Graph API endpoint, which reports cost aggregated by day, country and category.

Processing Modes

Batch Mode (Default)

Messages are collected in a time window before being processed together. Ideal for AI chatbots that need context from multiple messages.

'processing_mode' => 'batch',
'batch_window_seconds' => 3,  // Wait 3 seconds after last message
'batch_max_messages' => 10,   // Process after 10 messages regardless of time

Immediate Mode

Each message is processed immediately as it arrives.

'processing_mode' => 'immediate',

Batch Processing Architecture

Understanding how messages flow through the system helps configure it correctly.

Flow Diagram

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                           MESSAGE PROCESSING FLOW                               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  WhatsApp User                        Your Server
       β”‚
       β”‚  Sends message (text, audio, image, etc.)
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Meta API   │──────────────────────────────────────────────────────────────────┐
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                                                  β”‚
                                                                                  β–Ό
                                                                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                                                    β”‚  WebhookController   β”‚
                                                                    β”‚  (validates sig)     β”‚
                                                                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                                               β”‚
                                                                               β–Ό
                                                                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                                                    β”‚  WebhookProcessor    β”‚
                                                                    β”‚  β€’ Creates Message   β”‚
                                                                    β”‚  β€’ Creates Convo     β”‚
                                                                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                                               β”‚
                                                                               β–Ό
                                                              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                                              β”‚ WhatsAppProcessIncomingMessage β”‚
                                                              β”‚ (Job - runs async on queue)    β”‚
                                                              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                                               β”‚
                                              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                              β”‚                                                                 β”‚
                                              β–Ό                                                                 β–Ό
                                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                    β”‚  IMMEDIATE MODE   β”‚                                             β”‚    BATCH MODE     β”‚
                                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                              β”‚                                                                 β”‚
                                              β”‚                                                                 β–Ό
                                              β”‚                                                   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                              β”‚                                                   β”‚ Find/Create Batch         β”‚
                                              β”‚                                                   β”‚ β€’ Atomic transaction      β”‚
                                              β”‚                                                   β”‚ β€’ Lock for update         β”‚
                                              β”‚                                                   β”‚ β€’ Set process_after       β”‚
                                              β”‚                                                   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                              β”‚                                                                 β”‚
                                              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                                        β”‚
                                                           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                                           β”‚                         β”‚
                                                           β–Ό                         β–Ό
                                                   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                                   β”‚  Has Media?   β”‚         β”‚   No Media    β”‚
                                                   β”‚     YES       β”‚         β”‚               β”‚
                                                   β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜         β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                                                           β”‚                         β”‚
                                                           β–Ό                         β”‚
                                              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”‚
                                              β”‚ WhatsAppDownloadMedia  β”‚             β”‚
                                              β”‚ (Job - downloads file) β”‚             β”‚
                                              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β”‚
                                                           β”‚                         β”‚
                                              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”            β”‚
                                              β”‚                         β”‚            β”‚
                                              β–Ό                         β–Ό            β”‚
                                      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
                                      β”‚ Audio + Trans β”‚         β”‚  Other Media  β”‚    β”‚
                                      β”‚   Enabled?    β”‚         β”‚   or Failed   β”‚    β”‚
                                      β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜         β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
                                              β”‚                         β”‚            β”‚
                                              β–Ό                         β”‚            β”‚
                                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”            β”‚            β”‚
                                 β”‚ WhatsAppTranscribeAudio β”‚            β”‚            β”‚
                                 β”‚ (Job - calls OpenAI)    β”‚            β”‚            β”‚
                                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜            β”‚            β”‚
                                              β”‚                         β”‚            β”‚
                                              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                           β”‚
                                                           β–Ό
                                              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                              β”‚   message.markAsReady  β”‚
                                              β”‚   status = 'ready'     β”‚
                                              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                           β”‚
                                                           β–Ό
                                              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                              β”‚ WhatsAppCheckBatchReady│◄─────────── Scheduled check
                                              β”‚ β€’ All messages ready?  β”‚             (process_after + 1s)
                                              β”‚ β€’ Window elapsed?      β”‚
                                              β”‚ β€’ Max messages?        β”‚
                                              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                           β”‚
                                          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                          β”‚                                 β”‚
                                          β–Ό                                 β–Ό
                                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                  β”‚  NOT READY    β”‚                 β”‚    READY!     β”‚
                                  β”‚ (still proc.) β”‚                 β”‚               β”‚
                                  β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜                 β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                                          β”‚                                 β”‚
                                          β–Ό                                 β–Ό
                                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                β”‚ Re-check in 5s   β”‚             β”‚  WhatsAppProcessBatch β”‚
                                β”‚ (max 10 min)     β”‚             β”‚  β€’ Chronological lock β”‚
                                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β”‚  β€’ Calls your Handler β”‚
                                                                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                                            β”‚
                                                                            β–Ό
                                                                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                                                 β”‚  YourHandler::handle β”‚
                                                                 β”‚  (your business code)β”‚
                                                                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key Concepts

Batch Window (batch_window_seconds): Time to wait after the last message before processing. Each new message resets this timer, allowing users to send multiple messages that get grouped together.

Max Window (batch_max_window_seconds): Maximum total time a batch can stay open. Prevents infinite extension when users keep sending messages. After this time, the batch processes regardless of new messages.

Process After: The timestamp when a batch becomes eligible for processing. This is the minimum wait time - if messages are still downloading/transcribing, the batch waits until they're ready.

Message Status Flow:

received β†’ processing β†’ ready β†’ processed
              β”‚
              └──► (downloading media / transcribing audio)

Batch Status Flow:

collecting β†’ processing β†’ completed
                β”‚
                └──► failed (timeout or error)

Timing Configuration

Config Default Description
batch_window_seconds 3 Seconds to wait after last message. Resets with each new message.
batch_max_window_seconds 30 Maximum seconds a batch can stay open. Hard limit.
batch_max_messages 10 Process immediately when this many messages are collected.

Safety Mechanisms

1. Atomic Batch Creation: Batch creation and message association happen in a database transaction with row locking, preventing race conditions when multiple messages arrive simultaneously.

2. Chronological Processing: Batches for the same conversation are processed in order. Batch #2 waits for Batch #1 to complete, ensuring message ordering.

3. Timeout Protection (10 minutes): If media download or transcription takes too long, messages are force-marked as ready with error flags. The batch processes with available data rather than waiting forever.

4. Graceful Degradation: Failed downloads or transcriptions don't block processing. Your handler receives the messages with error flags so you can decide how to respond.

Example Scenarios

Scenario 1: User sends 3 quick texts

00:00 - "Hi"        β†’ Batch created, process_after = 00:03
00:01 - "I need"    β†’ Added to batch, process_after = 00:04
00:02 - "help"      β†’ Added to batch, process_after = 00:05
00:05 - Window elapsed, all ready β†’ Handler receives 3 messages

Scenario 2: User sends text + audio (with transcription)

00:00 - "Check this" (text)  β†’ Batch created, message ready
00:01 - [2min audio]         β†’ Added to batch, starts download
00:04 - Window elapsed BUT audio still processing β†’ Waits
00:15 - Download complete    β†’ Starts transcription
00:25 - Transcription done   β†’ Message ready
00:25 - All ready            β†’ Handler receives text + audio with transcription

Scenario 3: User keeps sending messages (max window protection)

00:00 - Msg 1 β†’ Batch created, process_after = 00:03
00:02 - Msg 2 β†’ process_after = 00:05
00:04 - Msg 3 β†’ process_after = 00:07
...
00:28 - Msg 15 β†’ process_after would be 00:31, BUT max_window (30s) caps it at 00:30
00:30 - Max window reached β†’ Handler receives all 15 messages

Scenario 4: Slow transcription with timeout

00:00 - [Long audio]         β†’ Batch created, starts download
03:00 - Download complete    β†’ Starts transcription
10:00 - TIMEOUT (10 min)     β†’ Message forced to ready with error
10:00 - Handler receives message with transcription_status = 'failed'

Recommended Configurations

For AI Chatbots (collect context):

'batch_window_seconds' => 5,       // Wait for user to finish typing
'batch_max_window_seconds' => 60,  // Allow longer conversations
'batch_max_messages' => 20,        // Higher limit for context
'transcription_enabled' => true,   // Understand voice messages

For Quick Support Bots (fast responses):

'batch_window_seconds' => 2,       // Quick turnaround
'batch_max_window_seconds' => 15,  // Don't wait too long
'batch_max_messages' => 5,         // Process smaller batches

For Immediate Processing (no batching):

'processing_mode' => 'immediate',  // Each message processed alone

Message Type Filtering

Control which message types are accepted:

// In config/whatsapp.php or per-phone in database
'allowed_message_types' => ['text', 'image', 'audio'], // Only these types
'allowed_message_types' => ['*'],  // All types (default)

// What to do with disallowed types
'on_disallowed_type' => 'ignore',     // Silently ignore
'on_disallowed_type' => 'auto_reply', // Send configured reply
'disallowed_type_reply' => 'Sorry, we only accept text messages.',

Console Commands

# Install the package
php artisan whatsapp:install

# Sync message templates from Meta
php artisan whatsapp:sync-templates

# Process stale/stuck batches (runs automatically every 5 min)
php artisan whatsapp:process-stale-batches

# Generate the RSA key pair for endpoint-backed Flows
php artisan whatsapp:flow-key generate

# Upload the Flow public key to Meta for a phone
php artisan whatsapp:flow-key upload --phone=support

# Smoke-test a flow against real traffic
php artisan whatsapp:flow-test send --phone=support --flow-id=123 --to=5511999999999
php artisan whatsapp:flow-test ping

whatsapp:flow-test send delivers a real flow message (in draft mode by default, so the flow does not need publishing) and prints the wamid Meta returned, or the Graph API error if it was rejected. whatsapp:flow-test ping encrypts a health check the way Meta does and posts it to your own endpoint, proving the private key, the route and the response encryption line up β€” a 421 there means the configured key does not match the one uploaded to Meta.

Queue Configuration

The package uses Laravel's queue system for async processing. Configure in config/whatsapp.php:

'queue' => [
    'connection' => env('WHATSAPP_QUEUE_CONNECTION'), // null uses default
    'queue' => env('WHATSAPP_QUEUE_NAME', 'default'),
],

Make sure to run your queue worker:

php artisan queue:work --queue=whatsapp

Testing

./vendor/bin/pest

License

MIT License. See LICENSE for details.