Looking to hire Laravel developers? Try LaraJobs

laravel-whatsapp-cloud-api maintained by blissjaspis

Description
Laravel package for interacting to whatsapp cloud api
Author
Last update
2026/03/30 05:11 (dev-main)
License
Links
Downloads
86

Comments
comments powered by Disqus

Laravel Whatsapp Cloud API

Latest Version on Packagist Total Downloads

Laravel package to interact with the WhatsApp Cloud API.

  • Outbound — send messages, media, templates, mark as read, and structured Graph API errors (sendOrFail()).
  • Inbound — webhook verification, payload parsing, inbound media helpers, delivery failure details, optional idempotency (you provide routes and business logic).

Requirements

  • PHP 8.2 or higher
  • Laravel 11, 12 or 13

Installation

composer require blissjaspis/laravel-whatsapp-cloud-api:^1.2

Publish the configuration file:

php artisan vendor:publish --provider="BlissJaspis\\WhatsappCloudApi\\WhatsappServiceProvider" --tag="config"

Use Outbound to send messages, Inbound to receive webhooks, or both. Each section below includes its own configuration and examples.

Outbound

Send messages and call the Graph API.

Configuration

Each connection is one Meta app. Credentials and Graph API version sit on the connection; phone numbers are nested under phones.

WHATSAPP_DEFAULT=default
WHATSAPP_VERSION_SDK=v25.0
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_ACCESS_TOKEN=
WHATSAPP_WEBHOOK_APP_SECRET=
WHATSAPP_WEBHOOK_VERIFY_TOKEN=
Variable Description
WHATSAPP_DEFAULT Default target: connection name or connection.phone (e.g. default.sales)
WHATSAPP_VERSION_SDK Graph API version for the default connection
WHATSAPP_PHONE_NUMBER_ID Phone number ID for the default connection (phones.main)
WHATSAPP_ACCESS_TOKEN System user access token for the default connection

Define connections in config/whatsapp-cloud-api.php:

'default' => env('WHATSAPP_DEFAULT', 'default'),

'connections' => [
    'default' => [
        'version_sdk' => env('WHATSAPP_VERSION_SDK', 'v25.0'),
        'access_token' => env('WHATSAPP_ACCESS_TOKEN'),
        'webhook_app_secret' => env('WHATSAPP_WEBHOOK_APP_SECRET'),
        'webhook_verify_token' => env('WHATSAPP_WEBHOOK_VERIFY_TOKEN'),
        'default_phone' => 'main',
        'phones' => [
            'main' => ['phone_number_id' => env('WHATSAPP_PHONE_NUMBER_ID')],
            'sales' => ['phone_number_id' => env('WHATSAPP_SALES_PHONE_NUMBER_ID')],
        ],
    ],
    'brand_b' => [
        'version_sdk' => env('WHATSAPP_BRAND_B_VERSION_SDK', 'v25.0'),
        'access_token' => env('WHATSAPP_BRAND_B_ACCESS_TOKEN'),
        'webhook_app_secret' => env('WHATSAPP_BRAND_B_WEBHOOK_APP_SECRET'),
        'phones' => [
            'support' => ['phone_number_id' => env('WHATSAPP_BRAND_B_PHONE_NUMBER_ID')],
        ],
    ],
],

Send from a connection or a specific phone:

Whatsapp::connection('default.sales')
    ->message()
    ->to('6281234567890')
    ->body(Text::message('Hello from sales')->build())
    ->send();

Whatsapp::connection('brand_b')->media()->upload($path, Media::ImageJPEG);
Whatsapp::connection('default.sales')->readMessage('wamid.xxx');

Whatsapp::message() without connection() uses the target named in WHATSAPP_DEFAULT (default: default).

Validate configuration

List connections and spot missing tokens or phone number IDs:

php artisan whatsapp:connections
php artisan whatsapp:connections --validate   # exit code 1 when invalid

Optional boot-time checks (reports to logs, does not stop the app):

WHATSAPP_VALIDATE_ON_BOOT=true

Core pattern

All outbound messages follow the same pattern:

Whatsapp::message()
    ->to($phoneNumber)
    ->body($messageBuilder->build())
    ->send(); // Illuminate\Http\Client\Response

Use ->sendOrFail() instead of ->send() when you want a WhatsappApiException on Graph API errors (see Graph API errors).

Use the Whatsapp facade or resolve BlissJaspis\WhatsappCloudApi\Whatsapp from the container.

Graph API errors

Failed requests return a normal Response from send(). For structured Meta error fields, use sendOrFail() or WhatsappApiException::throwIfFailed():

use BlissJaspis\WhatsappCloudApi\Exceptions\WhatsappApiException;
use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;
use BlissJaspis\WhatsappCloudApi\Support\Text;

try {
    Whatsapp::connection('default')
        ->message()
        ->to('6281234567890')
        ->body(Text::message('Hello')->build())
        ->sendOrFail();
} catch (WhatsappApiException $e) {
    // $e->getMessage()
    // $e->getCode() — HTTP status (400, 401, …)
    // $e->apiCode, $e->errorSubcode, $e->type, $e->fbtraceId, $e->error
}

$response = Whatsapp::message()->to('628...')->body(...)->send();
WhatsappApiException::throwIfFailed($response);

Media and read receipts use the same *OrFail() helpers:

use BlissJaspis\WhatsappCloudApi\Enums\Media;
use BlissJaspis\WhatsappCloudApi\WhatsappRead;

$upload = Whatsapp::connection('default')->media()->uploadOrFail('/path/to/file.jpg', Media::ImageJPEG);

(new WhatsappRead('default.sales'))->sendOrFail('wamid.xxx');

Phone numbers

The package passes the recipient number to the API as-is. Format the number in your application before calling to(). WhatsApp expects digits only with the country code included and no + prefix (e.g. 6281234567890).

Text messages

use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;
use BlissJaspis\WhatsappCloudApi\Support\Text;

$response = Whatsapp::message()
    ->to('6281234567890')
    ->body(Text::message('Hello from Laravel!')->build())
    ->sendOrFail();

$messageId = $response->json('messages.0.id');

Disable link preview:

Text::message('Visit https://example.com')->disableLinkPreview()->build();

Replying to a message

Whatsapp::message()
    ->to('6281234567890')
    ->replyTo('wamid.xxx')
    ->body(Text::message('Thanks for your message!')->build())
    ->send();

Media messages

Media builders accept an uploaded media ID (asset) or a public HTTPS URL (url).

Image

use BlissJaspis\WhatsappCloudApi\Support\Image;

// By URL
Whatsapp::message()
    ->to('6281234567890')
    ->body(Image::media('https://example.com/image.jpg', 'url')->build())
    ->send();

// By media ID with caption
Whatsapp::message()
    ->to('6281234567890')
    ->body(Image::media('your-media-id')->caption('Your caption')->build())
    ->send();

Document

use BlissJaspis\WhatsappCloudApi\Support\Document;

Whatsapp::message()
    ->to('6281234567890')
    ->body(
        Document::media('https://example.com/document.pdf', 'url')
            ->filename('invoice.pdf')
            ->caption('Your invoice')
            ->build()
    )
    ->send();

Audio

use BlissJaspis\WhatsappCloudApi\Support\Audio;

Whatsapp::message()
    ->to('6281234567890')
    ->body(Audio::media('your-media-id')->build())
    ->send();

Video

use BlissJaspis\WhatsappCloudApi\Support\Video;

Whatsapp::message()
    ->to('6281234567890')
    ->body(Video::media('your-media-id')->caption('Optional caption')->build())
    ->send();

Sticker

use BlissJaspis\WhatsappCloudApi\Support\Sticker;

Whatsapp::message()
    ->to('6281234567890')
    ->body(Sticker::media('your-media-id')->build())
    ->send();

Template messages

use BlissJaspis\WhatsappCloudApi\Support\Template;

// Static template (no variables)
Whatsapp::message()
    ->to('6281234567890')
    ->body(Template::name('hello_world')->lang('en')->build())
    ->send();

// Template with variables (fluent components)
use BlissJaspis\WhatsappCloudApi\Support\Template\BodyComponent;
use BlissJaspis\WhatsappCloudApi\Support\Template\ButtonComponent;
use BlissJaspis\WhatsappCloudApi\Support\Template\HeaderComponent;

Whatsapp::message()
    ->to('6281234567890')
    ->body(
        Template::name('order_update')
            ->lang('en')
            ->components([
                HeaderComponent::text('Your order'),
                BodyComponent::make()->text('John')->text('#12345'),
                ButtonComponent::url(0, 'https://example.com/orders/12345'),
            ])
            ->build()
    )
    ->send();

BodyComponent also supports currency() and dateTime() for non-text variables. Use raw components([...]) arrays for uncommon template layouts.

Optional category when your WhatsApp Business setup requires it:

Template::name('promo_offer')->lang('en')->category('marketing')->build();

Location

use BlissJaspis\WhatsappCloudApi\Support\Location;

Whatsapp::message()
    ->to('6281234567890')
    ->body(
        Location::mark()
            ->latitude(-6.2088)
            ->longitude(106.8456)
            ->name('Jakarta')
            ->address('Indonesia')
            ->build()
    )
    ->send();

Location request

use BlissJaspis\WhatsappCloudApi\Support\LocationRequest;

Whatsapp::message()
    ->to('6281234567890')
    ->body(LocationRequest::message('Please share your location.')->build())
    ->send();

Interactive messages

List message with fluent action helpers:

use BlissJaspis\WhatsappCloudApi\Support\Interactive;
use BlissJaspis\WhatsappCloudApi\Support\Interactive\ListAction;
use BlissJaspis\WhatsappCloudApi\Support\Interactive\ReplyButtonAction;

Whatsapp::message()
    ->to('6281234567890')
    ->body(
        Interactive::list()
            ->body('Choose an option')
            ->header('Menu')
            ->action(
                ListAction::make('View options')
                    ->section('Section 1')
                    ->row('row_1', 'Option 1', 'First option')
            )
            ->build()
    )
    ->send();

// Reply buttons (up to 3)
Whatsapp::message()
    ->to('6281234567890')
    ->body(
        Interactive::button()
            ->body('Do you agree?')
            ->action(
                ReplyButtonAction::make()
                    ->reply('yes', 'Yes')
                    ->reply('no', 'No')
            )
            ->build()
    )
    ->send();

Fluent entry points for other interactive types (pass a raw action array for the payload):

Interactive::flow()->body('Complete the form')->action([/* flow action */])->build();
Interactive::productList()->body('Browse')->action([/* catalog */])->build();
Interactive::catalog()->body('View catalog')->action([/* catalog_message */])->build();

Contacts and reactions

use BlissJaspis\WhatsappCloudApi\Support\Contacts;
use BlissJaspis\WhatsappCloudApi\Support\Reaction;

Whatsapp::message()
    ->to('6281234567890')
    ->body(Contacts::data([/* WhatsApp contacts array */])->build())
    ->send();

Whatsapp::message()
    ->to('6281234567890')
    ->body(Reaction::messageId('wamid.xxx')->emoji('👍')->build())
    ->send();

Media upload, retrieve, and delete

use BlissJaspis\WhatsappCloudApi\Enums\Media;
use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;
use BlissJaspis\WhatsappCloudApi\Support\Image;

$upload = Whatsapp::media()->upload('/path/to/image.jpg', Media::ImageJPEG);
$mediaId = $upload->json('id');

Whatsapp::message()
    ->to('6281234567890')
    ->body(Image::media($mediaId)->build())
    ->send();

$metadata = Whatsapp::media()->retrieve($mediaId);
$binary = Whatsapp::media()->download($metadata->json('url'));
Whatsapp::media()->delete($mediaId);

// Or throw WhatsappApiException on failure:
$metadata = Whatsapp::media()->retrieveOrFail($mediaId);
$binary = Whatsapp::media()->downloadOrFail($metadata->json('url'));

Mark a message as read

use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;

// Uses WHATSAPP_DEFAULT connection
Whatsapp::readMessage('wamid.xxx');
Whatsapp::readMessage('wamid.xxx', withTypingIndicator: true);

// Named connection with sendOrFail()
use BlissJaspis\WhatsappCloudApi\WhatsappRead;

(new WhatsappRead('default.sales'))->sendOrFail('wamid.xxx');

Inbound

Receive WhatsApp webhooks in your Laravel app. You provide routes, controllers, jobs, and business logic; this package supplies verification and optional idempotency helpers.

Configuration

Webhook credentials are set per Meta app under connections (see Outbound configuration):

'connections' => [
    'default' => [
        'webhook_app_secret' => env('WHATSAPP_WEBHOOK_APP_SECRET'),
        'webhook_verify_token' => env('WHATSAPP_WEBHOOK_VERIFY_TOKEN'),
        // ...
    ],
],
Config key Description
connections.*.webhook_app_secret App Secret (Meta Developer Console → App settings → Basic)
connections.*.webhook_verify_token Same token as in your Meta webhook subscription
webhook_idempotency Optional: prefix, ttl, cache_store (see below)

Subscription challenge (GET)

When you configure the webhook URL in Meta, respond to the verification request:

use BlissJaspis\WhatsappCloudApi\Webhooks\WebhookChallenge;
use Illuminate\Http\Request;

Route::get('/webhook/whatsapp', fn (Request $request) => WebhookChallenge::toResponse($request));

Signature verification (POST)

Verify X-Hub-Signature-256 using the raw request body and your app secret:

use BlissJaspis\WhatsappCloudApi\Http\Middleware\VerifyWhatsappWebhookSignature;

Route::post('/webhook/whatsapp', function (Request $request) {
    // Handle payload in your app
})->middleware(VerifyWhatsappWebhookSignature::class);

When no middleware parameter is passed, the app secret is read from the default connection (WHATSAPP_DEFAULT, usually default).

Pass a connection name (from connections) or a literal app secret after ::

Route::post('/webhook/whatsapp', function (Request $request) {
    // Handle payload in your app
})->middleware(VerifyWhatsappWebhookSignature::class . ':your-meta-app-secret');

// Connection name (per Meta app — not connection.phone)
Route::post('/webhook/whatsapp/support', $handler)
    ->middleware(VerifyWhatsappWebhookSignature::class . ':support');

Or verify manually:

use BlissJaspis\WhatsappCloudApi\Webhooks\WebhookSignature;

WebhookSignature::assertValidRequest($request); // uses config; throws InvalidWebhookSignature
WebhookSignature::assertValidRequest($request, 'custom-app-secret');

Note: Signature verification uses the raw request body. Do not parse the JSON before the middleware runs. If your app secret contains :, Laravel splits middleware parameters on that character — use config only or WebhookSignature::verifyRequest($request, $secret) instead.

Idempotency (optional)

Meta may retry webhooks. Use WebhookPayload to extract message/status IDs and skip duplicates:

use BlissJaspis\WhatsappCloudApi\Contracts\WebhookIdempotencyStore;
use BlissJaspis\WhatsappCloudApi\Http\Middleware\EnsureWhatsappWebhookIdempotency;
use BlissJaspis\WhatsappCloudApi\Webhooks\WebhookPayload;

Route::post('/webhook/whatsapp', function (Request $request) {
    // ...
})->middleware([
    VerifyWhatsappWebhookSignature::class,
    EnsureWhatsappWebhookIdempotency::class,
]);

Configure webhook_idempotency in config/whatsapp-cloud-api.php (default TTL: 24 hours). Bind your own WebhookIdempotencyStore for database-backed deduplication. For production, use Redis via WHATSAPP_WEBHOOK_IDEMPOTENCY_CACHE_STORE.

Payload parsing

Parse nested Meta payloads into typed events instead of drilling into entry[0]['changes'][0] manually:

use BlissJaspis\WhatsappCloudApi\Webhooks\Events\IncomingMessage;
use BlissJaspis\WhatsappCloudApi\Webhooks\Events\MessageStatus;
use BlissJaspis\WhatsappCloudApi\Webhooks\WebhookPayload;

Route::post('/webhook/whatsapp', function (Request $request) {
    foreach (WebhookPayload::events($request->all()) as $event) {
        if ($event instanceof MessageStatus) {
            // delivery/read receipts
            continue;
        }

        if (! $event instanceof IncomingMessage || ! $event->isMessagesField()) {
            continue;
        }

        if ($reply = $event->interactiveReply()) {
            ProcessInteractiveResponseJob::dispatch($event->raw(), $reply->id);

            continue;
        }

        if ($text = $event->textBody()) {
            // handle plain text
        }

        if ($mediaId = $event->mediaId()) {
            // image, video, audio, document, or sticker — see inbound media below
        }
    }
})->middleware(VerifyWhatsappWebhookSignature::class);

Helpers:

Method Returns
WebhookPayload::events() All parsed events (IncomingMessage, MessageStatus, MessageTemplateStatusUpdate, UnknownWebhookEvent)
WebhookPayload::incomingMessages() Incoming messages only
WebhookPayload::messageStatuses() Delivery/read/failed statuses only
WebhookPayload::messageTemplateStatusUpdates() Template approval/rejection updates from Meta

IncomingMessage

Method Description
textBody(), hasTextBody() Plain text messages
interactiveReply(), buttonReplyId(), listReplyId() Button/list/NFM replies
mediaId(), mimeType(), sha256(), mediaCaption(), documentFilename() Inbound image/video/audio/document/sticker
location(), isLocation() Shared location coordinates and label
from(), phoneNumberId(), messageType(), raw() Routing and debugging

Unknown webhook fields are returned as UnknownWebhookEvent so you can still read value() without the parser failing.

MessageStatus and delivery failures

foreach (WebhookPayload::messageStatuses($request->all()) as $status) {
    if ($status->isFailed() && ($error = $status->firstError())) {
        // $error->code, $error->title, $error->message, $error->details
    }

    if ($status->isDelivered()) {
        // ...
    }
}

errors() returns a list of WhatsappDeliveryError value objects parsed from Meta's statuses[].errors payload.

Template status updates

use BlissJaspis\WhatsappCloudApi\Webhooks\Events\MessageTemplateStatusUpdate;

foreach (WebhookPayload::messageTemplateStatusUpdates($request->all()) as $event) {
    if ($event->isApproved()) {
        // $event->templateName(), $event->templateLanguage()
    }
}

Download inbound media

When a user sends media, use mediaId() with the same connection that received the message:

use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;
use BlissJaspis\WhatsappCloudApi\Webhooks\Events\IncomingMessage;

if ($event instanceof IncomingMessage && ($mediaId = $event->mediaId())) {
    $meta = Whatsapp::connection($connection->name)->media()->retrieveOrFail($mediaId);
    $file = Whatsapp::connection($connection->name)->media()->downloadOrFail($meta->json('url'));

    // $event->mimeType(), $event->sha256(), $event->mediaCaption()
}

Route inbound messages to a connection (multiple numbers)

One webhook URL can receive events from every number in the same Meta app. Use phoneNumberId() to pick the connection, then reply from that number:

use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;
use BlissJaspis\WhatsappCloudApi\Webhooks\Events\IncomingMessage;
use BlissJaspis\WhatsappCloudApi\WhatsappConnectionManager;

foreach (WebhookPayload::events($request->all()) as $event) {
    if (! $event instanceof IncomingMessage) {
        continue;
    }

    $connection = app(WhatsappConnectionManager::class)
        ->findByPhoneNumberId($event->phoneNumberId() ?? '');

    if ($connection === null) {
        continue;
    }

    Whatsapp::connection($connection->name)
        ->message()
        ->to($event->from() ?? '')
        ->body(Text::message('Thanks!')->build())
        ->send();
}

HTTP observability

Before each Graph API request, the package dispatches BlissJaspis\WhatsappCloudApi\Events\HttpSending with the pending HTTP client and resolved WhatsappConnection. Listen in your app to log URLs, connection names, or add headers:

use BlissJaspis\WhatsappCloudApi\Events\HttpSending;

Event::listen(HttpSending::class, function (HttpSending $event) {
    logger()->info('WhatsApp API request', [
        'connection' => $event->connection->name,
        'phone_number_id' => $event->connection->phoneNumberId,
    ]);
});

Queue outbound messages

The package does not queue messages automatically. Dispatch a job from your app:

use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;

SendWhatsappTextJob::dispatch('6281234567890', 'Hello');

// Job example:
final class SendWhatsappTextJob implements ShouldQueue
{
    public function __construct(
        private string $to,
        private string $body,
        private ?string $connection = null,
    ) {}

    public function handle(): void
    {
        $whatsapp = $this->connection
            ? Whatsapp::connection($this->connection)
            : Whatsapp::message();

        $whatsapp->to($this->to)
            ->body(['type' => 'text', 'text' => ['body' => $this->body]])
            ->sendOrFail();
    }
}

Testing

Fake outbound calls without hitting Meta (requires phpunit/phpunit in your app for assertion helpers):

use BlissJaspis\WhatsappCloudApi\Facades\Whatsapp;

Whatsapp::fake();

Whatsapp::message()
    ->to('6281234567890')
    ->body(['type' => 'text', 'text' => ['body' => 'Hello']])
    ->send();

Whatsapp::fake()->assertSentTo('6281234567890');
composer lint

Laravel Boost

This package includes a Laravel Boost agent skill at resources/boost/skills/laravel-whatsapp-cloud-api/SKILL.md to help AI agents generate correct integration code.

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security Vulnerabilities

Please review our security policy on how to report security vulnerabilities.

Credits

License

The MIT License (MIT). Please see License File for more information.