multek / laravel-whatsapp-cloud
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
Requires
- php: ^8.2
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.0
- openai-php/client: ^0.10
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^2.0|^3.0|^4.0
- pestphp/pest-plugin-laravel: ^2.0|^3.0|^4.0
- phpstan/phpstan: ^1.0
Suggests
- openai-php/client: Required for OpenAI Whisper transcription (^0.10)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-06 20:21:47 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 ranvendor:publish --tag=whatsapp-migrationsand customized your copy, callWhatsAppServiceProvider::ignoreMigrations()from your own service provider'sregister()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.