Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Whatsapp Cloud Api Laravel Package

netflie/whatsapp-cloud-api

Send WhatsApp messages from any PHP app or Laravel project with this fluent client for the official WhatsApp Cloud API. Build text, media, template, and interactive messages, and send them via Laravel notifications or plain PHP—no extra services needed.

View on GitHub
Deep Wiki
Context7

Getting Started

This is a framework-agnostic PHP SDK that wraps Meta’s Graph API endpoints for the WhatsApp Business Platform (Cloud API). It is a thin transport/client library — it does not give you a CRM, conversation state, or template management. Treat it as "HTTP for WhatsApp" and build your own domain layer on top.

Install and configure

composer require netflie/whatsapp-cloud-api

You need three things from the Meta Business/Developer dashboard before anything works:

  1. A permanent access token (System User token, not the 24-hour test token).
  2. A phone number ID (from_phone_number_id) — this is the numeric ID of the sender, not the phone number itself.
  3. The WhatsApp Business Account ID (WABA) — needed for template management and some media calls.

If the version you installed ships the Laravel bridge, the service provider is auto-discovered; otherwise register it manually in bootstrap/providers.php and publish the config:

php artisan vendor:publish --tag=whatsapp-cloud-api
// config/whatsapp-cloud-api.php
return [
    'from_phone_number_id' => env('WHATSAPP_PHONE_NUMBER_ID'),
    'access_token'         => env('WHATSAPP_ACCESS_TOKEN'),
    'business_account_id'  => env('WHATSAPP_BUSINESS_ACCOUNT_ID'),
];

Resolve it from the container rather than new-ing it, so tests can swap it:

use Netflie\WhatsAppCloudApi\WhatsAppCloudApi;

$client = app(WhatsAppCloudApi::class);

$client->sendTextMessage('34612345678', 'Your order #1042 has shipped.');

Where to look first, in order

  1. The README’s "Send message" and "Webhook" examples — that is 90% of what you will ever do.
  2. src/Message/ — the typed message objects (Text, Template, Document, Image, Interactive*). Each maps 1:1 to a Graph API payload shape; reading these teaches you the API surface faster than the docs.
  3. src/Response/Response.php and ResponseException.php — how errors surface.
  4. src/Webhook/ — the notification/event objects you will convert into your own DTOs.

First real use case: outbound transactional notifications. Pick your single highest-volume message (order confirmation, OTP, appointment reminder), send it with a template, and wire the message.statuses webhook so you know whether it was delivered or failed. Do not start with a chatbot.

Implementation Patterns

Wrap the client in your own service. The SDK’s API is stable-ish but not your domain. Put a WhatsAppNotifier / WhatsAppChannel class between your app and the SDK so you can assert on your own interface in tests and swap providers later.

final class WhatsAppNotifier
{
    public function __construct(private WhatsAppCloudApi $client) {}

    public function sendOrderShipped(Order $order): string
    {
        $response = $this->client->sendTemplate(
            $order->customer->phone_e164,
            'order_shipped',
            'en_US',
            [ /* body component params, in order */ ]
        );

        return $response->decodedBody()['messages'][0]['id']; // wamid
    }
}

Persist the wamid. Every outbound message returns a message ID. Store it against your own record — it is the only key that lets you correlate later statuses and messages webhook events back to your domain object. Without it, delivery callbacks are unactionable noise.

Queue outbound sends. Never send synchronously inside a web request. Meta will occasionally take seconds, and a single 429 stalls your user’s page load. A ShouldQueue job with $tries = 3 and exponential backoff is the baseline.

final class SendWhatsAppMessage implements ShouldQueue
{
    public int $tries = 3;
    public array $backoff = [10, 60, 300];
}

Webhook: two distinct endpoints.

  • GET /webhooks/whatsapp — Meta’s verification handshake. Echo back hub_challenge only when hub_verify_token matches your configured secret. This runs once at setup and again if you change the URL.
  • POST /webhooks/whatsapp — the actual payload. Verify X-Hub-Signature-256 (an HMAC-SHA256 of the raw body keyed by the app secret) before parsing, then enqueue and return 200 immediately.
Route::post('/webhooks/whatsapp', function (Request $request) {
    $expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), config('services.meta.app_secret'));
    abort_unless(hash_equals($expected, $request->header('X-Hub-Signature-256', '')), 403);

    ProcessWhatsAppWebhook::dispatch($request->all());

    return response()->noContent();
})->withoutMiddleware([VerifyCsrfToken::class]);

Normalize inbound events into your own model. A webhook POST is a batch: entry[].changes[].value can contain messages, statuses, errors, and contacts all at once. Iterate every entry and every change rather than assuming a single message, then map to InboundMessage / DeliveryStatus DTOs. Handle at minimum: messages (text, media, button reply, interactive), statuses (sent → delivered → read → failed), and errors.

Idempotency on inbound. Meta retries webhook deliveries if you do not return 2xx quickly. Deduplicate on the message id (wamid) with a unique index in a whatsapp_inbound_messages table, and on (wamid, status) for statuses. A retried "read" receipt must not re-fire your analytics.

Template management. Templates must be created and approved in Meta Business Manager (this SDK can list/query them but approval is out of band). Keep the template name and component ordering in a config or enum — the SDK will happily send parameters in the wrong order and Meta will accept it, producing a garbled message. Treat parameter order as a contract and version template names (order_shipped_v2).

Media workflow. Two directions, both awkward:

  • Outbound: upload the file first (uploadMedia), get a media ID, then send a message referencing that ID. Or send a public HTTPS link directly — far simpler for CDN-hosted assets.
  • Inbound: the webhook gives you a media ID, which you exchange for a short-lived URL, which you then download with the same bearer token. Do this in a queued job; the URL expires in minutes.

Testing. Swap the underlying HTTP client with a PSR-18 mock (Guzzle’s MockHandler works) or bind a fake WhatsAppCloudApi in the container. Record a handful of real webhook payloads (text, template status, failed delivery, malformed) as fixtures and assert your parser handles them — payloads have quietly grown new fields over time.

Gotchas and Tips

The 24-hour customer service window is the trap that bites everyone. You can only send free-form messages (text, media, interactive) within 24 hours of the customer’s last inbound message. Outside that window, only approved templates go through; anything else returns error 131047 (Re-engagement message). Model this explicitly: store last_inbound_at per conversation and branch to a template when it is stale. Trying to "just retry" a rejected text message will burn your quality rating.

Errors come back as HTTP 4xx with a JSON error object — and the SDK throws. The exception carries the decoded body; catch it, log error.code and error.error_data.details, and classify:

  • 131047 — outside 24h window → fall back to template.
  • 132000 / 132001 — template param count mismatch or template not found.
  • 131026 — recipient cannot receive (not on WhatsApp / landline) → mark the contact invalid.
  • 130429 / HTTP 429 — throughput limit → back off and retry, do not hammer.
  • 190 — expired/invalid token → alert, this is an ops incident, not a retry.

Do not swallow exceptions into a generic false return; you lose the only diagnostic you have.

Phone numbers are E.164 without the + and without formatting. +34 612 345 678 becomes 34612345678. Strip spaces, dashes, and parentheses at the boundary of your system, not deep inside the SDK call. Store the canonical form on the user record.

Text messages cap at 4096 characters. Longer bodies are rejected. Chunk or send as a document; do not assume the SDK truncates for you.

Webhook signature verification requires the raw body. If you verify after Laravel has parsed the JSON, getContent() may already have been consumed or re-encoded and the HMAC will never match. Read $request->getContent() first, and be aware that VerifyCsrfToken must be excluded for the route.

Config caching will silently break env() calls. If you publish the config and then run php artisan config:cache in production, any env() you added inside the config file outside of a fresh cache build returns null. Always config:clear after env changes, and keep the access token in a single config/services.php entry.

The SDK is a thin passthrough, not a resilience layer. There is no automatic retry, no rate limiter, no circuit breaker. Wrap calls in Laravel’s RateLimiter / Http::retry semantics in your own job class, and set a sane HTTP timeout — the default client will happily hang on a slow Graph API response.

Version pinning matters. The Cloud API graph version is effectively baked into the SDK's endpoints in many releases. When Meta deprecates a graph version, you must upgrade the package — so pin it in composer.json and keep an eye on the changelog rather than floating ^x.0.

Do not send from inside a DB transaction. A slow or failing HTTP call holds row locks. Dispatch the job afterCommit().

Quality rating and messaging limits are account-level. Meta throttles based on your phone number’s quality rating and tier. If you notice messages failing in bulk, check the phone number status in Business Manager before assuming a bug in your code — a downgraded quality rating reduces throughput without any distinct error on every call.

Interactive messages are the highest-leverage feature and the easiest to get wrong. Button and list reply IDs come back in the webhook as interactive.button_reply.id — put your routing logic on those IDs (stable, short strings like confirm_order_1042) rather than on the display title, which you will inevitably change.

Extension point: the client accepts an injected PSR-18 HTTP client in most releases. That is your seam for adding logging middleware, request tracing headers, or a mock in tests. If you need per-request observability (latency, error rate by template), build it there rather than sprinkling Log::info around your jobs.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle