WhatsappToolkit. 2.0.0
WhatsappToolkit

Laravel 11 ยท 12 ยท 13  ยท  PHP 8.3+

Message received, message delivered

HTML in, WhatsApp markup out โ€” entities decoded, lists renumbered, link destinations kept. Then a URL that opens the right conversation.

Gabrielesbaiz · WhatsappToolkitNo. 2.0.0
Hi *Sarah* stop
quote Q-7 is ready stop
Cafรฉ & croissant stop
Delivered183 tests ยท 264 assertions
183 tests 264 assertions PHPStan level 6 MIT no required deps beyond Laravel

00 โ€” The smallest real thing

Three lines, and the link opens the right chat

The number is normalised, the body is percent-encoded exactly once, and the result is the short form WhatsApp publishes.

Input

use Gabrielesbaiz\WhatsappToolkit\Facades\WhatsappToolkit;

WhatsappToolkit::to('+39 333 123 4567')
    ->text('Hello')
    ->url();

Output

https://wa.me/393331234567?text=Hello

In 1.x the same call produced phone=%2B39+333+123+4567 โ€” a link that opened WhatsApp and no conversation.

01 โ€” Numbers

One column, six formats, one wa_id

Everything that is not a digit goes. The 00 access prefix goes. A national trunk zero goes. The configured country code is applied to bare local numbers, and the result has to be 7โ€“15 digits.

InputwaId()Why
+39 333 123 4567393331234567Punctuation stripped
(+39) 333/123-4567393331234567Same
0039 333 123456739333123456700 is an access prefix
333 123 4567393331234567Country code applied
039 234567839392345678Monza landline: 0 is a trunk prefix
+1 (202) 555-014712025550147Carries its own country code
The last two rows are the interesting pair: both open with the digits of the Italian country code, and only one of them is one. Length settles it โ€” a national number that long does not exist.

02 โ€” Formatting

Rich text in, WhatsApp markup out

Formatting returns plain text, never URL-encoded. That separation is what lets the same body go into a link, a Cloud API request and a log line.

Input

WhatsappToolkit::format('<p>Hi <b>Sarah</b> &amp; co.</p>');
WhatsappToolkit::formatMarkdown('**Hi** *Sarah*');
WhatsappToolkit::toHtml('*Hi* _Sarah_');

Output

Hi *Sarah* & co. *Hi* _Sarah_ <strong>Hi</strong> <em>Sarah</em>

03 โ€” Links

Stop writing the anchor by hand

Hand-written click-to-chat anchors go wrong the same two ways every time: the number loses its country code, and the & between query parameters is emitted raw into the markup.

Before โ€” the usual Blade

<a href="https://api.whatsapp.com/send
   ?phone={{ $phone }}&text={{ urlencode($subject) }}"
   target="_blank">Chat on WhatsApp</a>
Raw & ยท no country code ยท spaces as + ยท dead link when empty

After โ€” the component

<x-whatsapp-link :to="$dealer->mobile_phone"
                 :message="$body"
                 class="btn">
    Chat on WhatsApp
</x-whatsapp-link>
Escaped &amp; ยท country code applied ยท %20 for spaces ยท renders nothing when unusable

04 โ€” Sending

Dormant until you configure it

With the Cloud API off, no routes are registered, no credentials are read, and the notification channel returns without sending. Turn it on and the same builder sends.

Sending

WhatsappToolkit::to($dealer->mobile_phone)
    ->html($body)
    ->send();

Testing it

$whatsapp = WhatsappToolkit::fake();

$dealer->notify(new QuotationReady($q));

$whatsapp->assertSentTo('+39 333 123 4567');

The 24-hour window

Free-form messages only reach someone who wrote to you in the last 24 hours. Outside it, approved templates only โ€” and ReEngagementRequiredException says so.

Errors that name the fix

Meta's codes map to typed exceptions carrying the fbtrace_id support asks for. Only transient failures are retried.

A verified webhook

Opt-in route, HMAC over the raw body, repeat deliveries dropped. It dispatches events and stops.

Measured, not asserted

Formatting is on the hot path

composer bench runs the 2.0 formatter against a verbatim copy of the 1.x implementation. 2000 iterations, PHP 8.4.

Body1.x2.0 
Plain text6.14 ms0.20 ms31ร—
Small HTML16.33 ms8.61 ms1.9ร—
Rich editor output248.85 ms175.54 ms1.4ร—
Same body, memoisedโ€”1.07 ms~230ร—

Honestly

When not to use this

If all you want is a link to a chat, you do not need a package. WhatsApp publishes the URL format and it is two lines:

$url = 'https://wa.me/' . $phone . '?text=' . rawurlencode($message);

Write that and move on โ€” if $phone is already digits, if $message is already plain text, and if nobody will ever paste rich text into it. Those three conditions are what fail in practice.

The trade. The link half has no dependencies beyond Laravel and works the moment you install it. The sending half needs a Meta Business account, an approved template for anything outside a 24-hour window, and a System User token โ€” none of which this package can give you. It stays dormant until you configure it, and most applications never will.

Support this package

Free forever. Not free to maintain.

Nobody notices a phone number that was normalised correctly. The link opens the right conversation, the accented characters arrive as accented characters, and the work that produced that is invisible by design. What is not invisible is the maintenance behind it: Meta ships a new Graph API version, deprecates an error code, changes what a template rejects โ€” and a package that quietly kept working has to be made to keep working. It stays MIT either way.

Sponsoring buys the unglamorous half: testing against each new Laravel major, keeping the Cloud API mapping current with Meta's codes, answering issues from people whose numbers arrive in a format nobody anticipated, and holding the suite at 183 passing tests while all of that moves. It stays MIT either way.

Free
โญ

Star the repo

Thirty seconds, and it is the first signal other developers look at.

Free
๐Ÿ›

Open a good issue

A phone format that normalises wrong, with the input, is worth more than you think.

โค๏ธ

Sponsor from $5

Monthly, cancellable, and it pays for the compatibility work nobody sees.

๐Ÿข

Company tier

Your logo in the README and here, and a direct line for upgrade questions.

Why ask at all? This package is MIT and always will be. Nothing is paywalled, nothing phones home, and no formatter, link flavour or Cloud API feature is held back for sponsors. Sponsorship buys maintenance time, not features โ€” and if you cannot sponsor, the star and the bug report genuinely help.

Installation

One composer line, one env value, and links work. Everything else is optional.

Requirements

PHP8.3 or newer
Laravel11, 12 or 13
bacon/bacon-qr-codeSuggested โ€” only for QR codes
laravel/novaSuggested โ€” only for the bundled action
Meta WhatsApp Business accountOnly for sending

Nothing beyond illuminate/contracts, illuminate/support, spatie/laravel-package-tools and ext-json is required. Building a link never needs an HTTP client, a credential or a network โ€” an architecture test enforces that.

Install

composer require gabrielesbaiz/whatsapp-toolkit

The service provider and the WhatsappToolkit facade register themselves through package discovery.

Configure

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

The one setting worth putting in .env straight away is the country code your local numbers belong to:

WHATSAPP_COUNTRY_CODE=39
Set default_country_code to null if your contacts span several countries. A bare local number is then refused rather than guessed at, which is the safer failure.

Verify

php artisan whatsapp:url "333 123 4567" "<p>Hi <b>Sarah</b></p>"
Hi *Sarah* https://wa.me/393331234567?text=Hi%20%2ASarah%2A

php artisan whatsapp:status prints the resolved configuration. With the Cloud API off it says plainly that links work without any credentials at all.

Optional extras

# QR codes
composer require bacon/bacon-qr-code

# Sending, once you have a Meta Business account
WHATSAPP_CLOUD_ENABLED=true
WHATSAPP_PHONE_NUMBER_ID=123456789012345
WHATSAPP_BUSINESS_ACCOUNT_ID=098765432109876
WHATSAPP_ACCESS_TOKEN=EAAG...
The quickstart token expires after 24 hours. The one offered by the Meta app dashboard's panel cannot be renewed. Production needs a System User token โ€” Business Settings โ†’ System Users โ†’ Generate โ€” with whatsapp_business_messaging and whatsapp_business_management. whatsapp:status warns when a token looks temporary.

Guide

Links, numbers, formatting, QR codes and sending โ€” in the order you meet them.

The builder is immutable

Every method returns a new instance. Describe a message once and address it as many times as you need, without a shared object mutating inside a loop.

$reminder = WhatsappToolkit::chat()
    ->markdown('Quote **:number** expires tomorrow.');

foreach ($dealers as $dealer) {
    $links[$dealer->id] = $reminder->to($dealer->mobile_phone)->url();
}

Link flavours

FlavourProducesWhen
WaMehttps://wa.me/393331234567?text=โ€ฆThe default. Shortest URL, sparsest QR
Apihttps://api.whatsapp.com/send?phone=โ€ฆWhat 1.x produced
Webhttps://web.whatsapp.com/send?phone=โ€ฆDesktop-only back office
Deepwhatsapp://send?phone=โ€ฆOpens the app, never a browser
Businesshttps://wa.me/message/ABCDE12345A code replaces the number
use Gabrielesbaiz\WhatsappToolkit\Enums\LinkTarget;

$chat->url(LinkTarget::Deep);          // once
$chat->target(LinkTarget::Api)->url();  // for this chat

WhatsappToolkit::chat()->shortCode('ABCDE12345')->url();

In Blade

<x-whatsapp-link :to="$dealer->mobile_phone"
                 :message="$body"
                 class="btn btn-success">
    Chat on WhatsApp
</x-whatsapp-link>

<a href="@whatsappUrl($dealer->mobile_phone, $body)" target="_blank">WhatsApp</a>
AttributeDefaultWhat it does
toโ€”The recipient, in any format
messagenullThe prefilled body
formathtmlhtml, markdown or text
targetconfigwa.me, api, web, deep, business

Anything else you pass lands on the <a>. The component adds target="_blank" rel="noopener noreferrer", escapes the & between query parameters, and renders nothing at all when the number cannot be normalised โ€” a dead chat link is worse than no link.

Phone numbers

$number = WhatsappToolkit::number('0039 333/123-4567');

$number->waId();      // "393331234567"   โ† what WhatsApp dials
$number->e164();      // "+393331234567"  โ† what to store
$number->national();  // "3331234567"

WhatsappToolkit::tryNumber('nonsense');   // null, no exception

Validate with the rule, store with the cast:

use Gabrielesbaiz\WhatsappToolkit\Casts\AsWhatsappNumber;
use Gabrielesbaiz\WhatsappToolkit\Rules\WhatsappNumber;

$request->validate([
    'mobile_phone'  => ['required', new WhatsappNumber],
    'foreign_phone' => ['required', WhatsappNumber::international()],
    'uk_phone'      => ['required', new WhatsappNumber('44')],
]);

// in the model โ€” the column then always holds E.164
protected function casts(): array
{
    return ['mobile_phone' => AsWhatsappNumber::class];
}
When Meta hands you a wa_id โ€” in a send response or a webhook โ€” that value is canonical. Keep it with PhoneNumber::fromWaId() and address later messages with it; for a few countries it differs from the number you sent.

Formatting

HTMLBecomesHTMLBecomes
<b>, <strong>*bold*<ul><li>- item
<i>, <em>_italic_<ol><li>1. item, per list
<s>, <del>~strikethrough~<blockquote>> quoted
<code>, <pre>```monospace```<a href="u">t</a>t (u)

Entities are decoded, so &eacute; arrives as รฉ rather than literally โ€” while markup that was escaped in the source stays escaped: &lt;b&gt; comes out as the four characters <b>, not as bold. Links keep their destination; set format.links to strip for the old behaviour.

WhatsappToolkit::format('Go <a href="https://novias.it">here</a> now');
// "Go here (https://novias.it) now"

Named templates

Bodies used over and over belong in config rather than in a controller or a Nova action.

// config/whatsapp-toolkit.php
'templates' => [
    'quotation_followup' => 'Good morning :name, quote **:number** is ready.',
],
WhatsappToolkit::to($phone)
    ->template('quotation_followup', ['name' => $dealer->name, 'number' => $q->number])
    ->url();

Templates are Markdown, placeholders are :name style, and the longest key is replaced first so :name never eats the start of :name_full. These are local templates for prefilled links โ€” unrelated to the approved templates the Cloud API requires.

QR codes

composer require bacon/bacon-qr-code
$chat->qr();            // HtmlString: <svg โ€ฆ>
$chat->qr(480);         // a different size, just this once
$chat->qrDataUri();     // "data:image/svg+xml;base64,โ€ฆ"

QR always encodes the short wa.me form, whatever the configured default โ€” fewer characters make a sparser code, and a sparse code is what survives being printed on an invoice and photographed on a shop counter.

Sending through the Cloud API

WhatsappToolkit::to($dealer->mobile_phone)->html($body)->send();

$api = WhatsappToolkit::cloud();

$api->send(TemplateMessage::make('quotation_ready', 'it')->body($q->number)->to($phone));
$api->send(MediaMessage::id(MediaType::Document, $mediaId)
        ->filename('Quote Q-7.pdf')->to($phone));
$api->send(InteractiveButtonsMessage::make('Confirm?')
        ->button('yes', 'Yes')->button('no', 'No')->to($phone));

Every message validates itself in its constructor, so a fourth reply button or a 21-character button title fails while you are building it rather than after a round trip.

The 24-hour window. Free-form messages only reach someone who wrote to you in the last 24 hours. Outside it WhatsApp accepts approved templates only, and the package raises ReEngagementRequiredException. This package stores nothing, so tracking the window is yours โ€” the WhatsappMessageReceived event is where.

Notifications

public function via($notifiable): array
{
    return ['whatsapp'];
}

public function toWhatsapp($notifiable): WhatsappMessage
{
    return WhatsappMessage::template('quotation_ready', 'it')
        ->body($this->quotation->number);
}

// on the notifiable
public function routeNotificationForWhatsapp(): string
{
    return $this->mobile_phone;
}

Return ['to' => โ€ฆ, 'from' => $phoneNumberId] instead to send from a second business number. A notifiable with no route raises InvalidPhoneNumberException โ€” dropping a notification silently is the worse failure. With the Cloud API disabled the channel returns without sending, so a staging environment needs no branching.

Receiving replies

WHATSAPP_WEBHOOK_ENABLED=true
WHATSAPP_WEBHOOK_VERIFY_TOKEN=a-random-string-you-choose
WHATSAPP_APP_SECRET=the-app-secret-from-the-meta-dashboard
Event::listen(WhatsappMessageReceived::class, function ($event) {
    $event->message->from;         // "393331234567" โ€” the canonical wa_id
    $event->message->text;         // "Morning"
    $event->message->contactName;  // "Sarah"
    $event->message->mediaId();   // for an image, document, audio, video
});

Point Meta at https://your-app.test/whatsapp/webhook. Every request is verified against X-Hub-Signature-256; an unsigned one gets a bare 403. The package then dispatches events and does nothing else โ€” it never replies, never marks messages read, and never stores anything.

Nova

use Gabrielesbaiz\WhatsappToolkit\Nova\SendWhatsappMessage;

public function actions(NovaRequest $request): array
{
    return [(new SendWhatsappMessage)->onlyOnDetail()];
}

// on the model
public function whatsappRecipients(): Collection
{
    return collect([
        ['value' => $this->mobile_phone, 'label' => $this->name, 'group' => __('Contacts')],
    ]);
}

Nova is a suggested dependency โ€” nothing in this class is loaded unless you reference it. recipientsFrom('otherMethod') and recipients($rows) override where the list comes from.

Configuration

Every key in config/whatsapp-toolkit.php, its default, and what changing it does.

Publishing

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

Nothing needs publishing to build links. The defaults produce a working wa.me URL with the Italian country code applied to local numbers.

Cloud API

KeyDefaultWhat it does
cloud.enabledfalseThe whole sending layer, on or off
cloud.phone_number_idnullYour business number's id
cloud.business_account_idnullYour WABA id
cloud.access_tokennullSystem User token in production
cloud.api_version'v21.0'Graph API version
cloud.base_urlgraph.facebook.comPoint at a sandbox if you have one
cloud.timeout10Seconds
cloud.connect_timeout5Seconds
cloud.retry.times3Attempts, for transient failures only
cloud.retry.sleep200Backoff base, milliseconds
cloud.retry.max5000Backoff ceiling, milliseconds

Leaving enabled false costs nothing: no credentials are read, no routes are registered, and the notification channel returns without sending. That is what lets this package stay a link builder for the people who only ever wanted one.

Webhook and logging

KeyDefaultWhat it does
webhook.enabledfalseWhether the inbound route exists at all
webhook.path'whatsapp/webhook'Where it lives
webhook.domainnullRestrict it to one domain
webhook.middleware['api']Keep this minimal
webhook.verify_tokennullYour half of the subscription handshake
webhook.app_secretnullRequired โ€” an empty one rejects every request
webhook.idempotency.enabledtrueDrop repeat deliveries
webhook.idempotency.storenullUse a shared store in production
webhook.idempotency.ttl86400How long ids are remembered
logging.channelnullnull means nothing is logged
logging.redact_contenttrueKeep message bodies out of logs
An array cache store cannot deduplicate across processes. Meta delivers in parallel and redelivers on any non-2xx, so name a shared store in webhook.idempotency.store in production.

Commands

Two, both for finding out what the package will actually do before your application does it.

The commands

CommandWhat it does
whatsapp:url {phone} {message?}Print the formatted body and the link
whatsapp:url --target=wa.me, api, web, deep or business
whatsapp:statusShow the resolved configuration and ping the Cloud API

whatsapp:url

php artisan whatsapp:url "333 123 4567" "<p>Hi <b>Sarah</b></p>"
php artisan whatsapp:url "+39 333 123 4567" "Hello" --target=api
Hi *Sarah* https://wa.me/393331234567?text=Hi%20%2ASarah%2A

It prints the formatted body first and the link second, which is the quickest way to tell a formatting problem from a normalisation one.

whatsapp:status

Answers "why doesn't it send?" before anyone has to ask it. Nearly every Cloud API failure report turns out to be a missing credential or the 24-hour quickstart token.

php artisan whatsapp:status
Default country code ........ 39 Link target ................. https://wa.me/39000000000 Cloud API ................... disabled INFO Click-to-chat links work without any Cloud API credentials.

With the Cloud API enabled it masks the token, flags one that looks temporary, pings Meta and prints the number's verified name and quality rating.

API reference

Every public method, what it returns, and what it does.

The facade

MethodReturnsWhat it does
to($recipient)ChatStart a chat addressed to a number
chat()ChatStart one with no recipient yet
number($value)PhoneNumberNormalise, or throw
tryNumber($value)?PhoneNumberNormalise, or null
format($html)stringHTML โ†’ WhatsApp markup
formatMarkdown($md)stringMarkdown โ†’ WhatsApp markup
toHtml($text)stringWhatsApp markup โ†’ escaped HTML
formatAs($body, $format)stringEither, by MessageFormat
url($recipient, $html = null)stringThe one-liner
renderTemplate($name, $vals = [])stringFill a config template
cloud()CloudApiThe Cloud API client
fake()CloudApiFakeSwap the client for a recorder
qr()QrRendererThe QR renderer
defaultTarget()LinkTargetThe configured link flavour
maxLength()intThe configured body ceiling
overflowStrategy()stringtruncate, throw or ignore

Chat

Every setter returns a new instance.

MethodReturnsWhat it does
to($recipient)ChatAddress it; a string or a PhoneNumber
shortCode($code)ChatAddress a business short link instead
text($body)ChatA body already in WhatsApp markup
html($body)ChatA body from a rich-text editor
markdown($body)ChatA body in Markdown
template($name, $vals = [])ChatA body from config
target($target)ChatOverride the link flavour
truncate($limit)ChatCut the body at a word boundary
withoutPreview()ChatCloud API: no link preview card
message()MessageThe formatted body
url($target = null)stringThe click-to-chat URL
link($label, $attributes = [])HtmlStringAn escaped <a>
qr($size = null)HtmlStringAn SVG QR code
qrDataUri($size = null)stringThe same, as a data URI
send()MessageResponseDeliver it through the Cloud API
recipient()?PhoneNumberWho it is addressed to
toArray()array['to', 'text', 'url']

PhoneNumber and Message

MethodReturnsWhat it does
PhoneNumber::parse($v, $default = null)PhoneNumberNormalise, or throw. '' refuses local numbers
PhoneNumber::tryParse($v, $default = null)?PhoneNumberNormalise, or null
PhoneNumber::fromWaId($waId)PhoneNumberWrap a canonical id without re-deriving it
waId()stringDigits only โ€” what WhatsApp dials
e164()string+ and digits โ€” what to store
national()stringWithout the country code
equals($other)boolSame number
Message::length()intCharacters, counted with mb_strlen()
Message::truncate($limit, $ellipsis)MessageCut at a word boundary
Message::encoded()stringrawurlencoded, for a query string

CloudApi and messages

MethodReturnsWhat it does
send($message, $to = null)MessageResponseSend one message
sendMany($messages)arraySend several, in order
markRead($messageId)boolMark an inbound message read
uploadMedia($path, $mime = null)MediaResponseUpload a file, get an id
mediaUrl($mediaId)MediaResponseLook one up
downloadMedia($mediaId)stringFetch the bytes
post($endpoint, $payload)arrayEscape hatch for unmodelled endpoints
usingPhoneNumberId($id)staticSend from another business number
Message classBuilt with
TextMessage::make($body), ->previewUrl(false)
TemplateMessage::make($name, $lang), ->body(โ€ฆ), ->header(โ€ฆ), ->button($i, $payload)
MediaMessage::id($type, $id) or ::link($type, $url), ->caption(), ->filename()
InteractiveButtonsMessage::make($body), ->button($id, $title), ->header(), ->footer()
LocationMessage::make($lat, $lng), ->name(), ->address()
ReactionMessage::make($wamid, $emoji), ::remove($wamid)

The fake

AssertionPasses when
assertSent($type, $callback = null)A message of this class was sent, optionally matching a closure
assertNotSent($type)None of this class was
assertSentTo($recipient, $type = null)Something went to this number, written any way you like
assertSentCount($count)Exactly this many were sent
assertNothingSent()Nothing at all was
assertMarkedRead($messageId)markRead() was called for it
assertMediaUploaded($path = null)Something, or this file, was uploaded
sent($type = null)Returns the recorded messages as a Collection
push($response|$throwable)Queues the next result, so a test can force the unhappy path

Events and exceptions

EventDispatched when
WhatsappMessageReceivedSomeone wrote to your business number
WhatsappStatusUpdatedA message you sent was delivered or read
WhatsappMessageFailedA message you sent could not be delivered
WhatsappWebhookReceivedEvery verified payload, before it is split
WhatsappMessageSentOn the way out, when a send succeeds
Meta codesException
0, 3, 10, 190, 368AuthenticationException
4, 130429, 131048, 131056RateLimitException
131047ReEngagementRequiredException
131026, 131030, 131051, 133010RecipientException
131052, 131053MediaException
132000โ€“132016TemplateException
1, 2, 5xxTransientException

All of them extend CloudApiException, which extends WhatsappToolkitException โ€” catch that one type to contain the package entirely. Each carries errorCode(), fbtraceId(), status() and isRetryable().

Recipes

Whole solutions to things that actually come up.

A chat link on every row of a table

<@foreach ($dealers as $dealer)>
    <td>
        <x-whatsapp-link :to="$dealer->mobile_phone"
                         :message="$body"
                         class="btn btn-sm">WhatsApp</x-whatsapp-link>
    </td>
<@endforeach>

One body, many recipients: the formatted text is memoised for the request, so the conversion happens once no matter how many rows there are. Rows whose number cannot be normalised render no link rather than a broken one.

Migrating a column of messy phone numbers

Dealer::query()
    ->whereNotNull('mobile_phone')
    ->chunkById(500, function ($dealers) {
        foreach ($dealers as $dealer) {
            $number = WhatsappToolkit::tryNumber($dealer->mobile_phone);

            $dealer->update([
                'mobile_phone' => $number?->e164(),
                'whatsapp_reachable' => $number !== null,
            ]);
        }
    });

tryNumber() rather than number(), so one unusable row does not stop the migration. Add the AsWhatsappNumber cast afterwards and new rows normalise themselves.

A QR code on a printed invoice

$chat = WhatsappToolkit::to(config('company.support_phone'))
    ->template('invoice_question', ['number' => $invoice->number]);

$pdf = Pdf::loadView('invoices.show', [
    'invoice' => $invoice,
    'qr' => $chat->qrDataUri(),
]);

A data URI embeds without a second HTTP request, which is what a PDF renderer needs. Keep the prefilled body short โ€” every character makes the code denser.

Queueing a template send that respects rate limits

public function handle(): void
{
    try {
        WhatsappToolkit::cloud()->send(
            TemplateMessage::make('quotation_ready', 'it')
                ->body($this->quotation->number)
                ->to($this->dealer->mobile_phone)
        );
    } catch (RateLimitException $e) {
        $this->release($e->retryAfter() ?? 60);
    } catch (ReEngagementRequiredException|RecipientException $e) {
        $this->fail($e);   // neither will ever succeed on a retry
    }
}

Recording the 24-hour window

Event::listen(WhatsappMessageReceived::class, function ($event) {
    Dealer::where('wa_id', $event->message->from)->update([
        'whatsapp_window_opened_at' => now(),
    ]);
});

$dealer->whatsapp_window_opened_at?->diffInHours() < 24
    ? WhatsappToolkit::to($dealer->wa_id)->text($body)->send()
    : WhatsappToolkit::cloud()->send(
        TemplateMessage::make('followup', 'it')->to($dealer->wa_id));

The package stores nothing, so the window is yours to track. The listener matches on wa_id โ€” the canonical id Meta gave you โ€” not on the number a human typed.

Showing a reply in the browser

<div class="message">{!! WhatsappToolkit::toHtml($message->body) !!}</div>

toHtml() escapes before it adds any markup, so {!! !!} is safe here โ€” the only tags that survive are the ones it added itself.

Swapping the package out in a test

WhatsappToolkit::shouldReceive('url')->andReturn('https://wa.me/000?text=x');

The facade resolves a real singleton through the container in 2.0, so this works. It could not in 1.x, where every method was static.

Troubleshooting

The errors and symptoms people actually hit, and what each one means.

The link opens WhatsApp but no conversation

The number never reached WhatsApp in a form it could dial. In 1.x this was the normal outcome: formatPhoneNumber() only url-encoded.

php artisan whatsapp:url "+39 333 123 4567" "test"

If the digits look right there and wrong in your application, something is bypassing the package โ€” a hand-written anchor in Blade, or a '+39' . $phone concatenation upstream.

InvalidPhoneNumberException on a number that looks fine

Three causes, in order of likelihood. The number is local and default_country_code is null. It holds fewer than 7 or more than 15 digits once punctuation is stripped โ€” an internal extension, usually. Or it is not a number at all: an empty string, a placeholder like -, or a note someone typed into the phone column. Use tryNumber() for data you do not control.

The message shows &eacute; or &amp; in the chat

You are formatting with something other than this package, or you are on 1.x, which never decoded entities. format() decodes them after stripping tags.

The body shows + signs where the spaces should be

Something used urlencode(), which writes a space as +. This package uses rawurlencode(), which writes %20. If you are building part of the URL yourself, stop โ€” url() encodes the body exactly once.

ConfigurationException: the Cloud API layer is disabled

cloud.enabled is false, which is the default and is deliberate. Set WHATSAPP_CLOUD_ENABLED=true and the three ids, then run php artisan whatsapp:status. If you meant to build a link rather than send, use url() instead of send().

AuthenticationException, code 190, and it worked yesterday

The token expired. The one offered by the Meta dashboard's quickstart panel lasts 24 hours and cannot be renewed. Generate a System User token with whatsapp_business_messaging and whatsapp_business_management.

ReEngagementRequiredException on a message that used to go through

More than 24 hours have passed since that contact last wrote to you. Outside that window WhatsApp delivers approved templates only. Send a TemplateMessage, or track the window yourself.

TemplateException, code 132001 or 132000

132001 means no approved template with that name and that language exists โ€” it and it_IT are two different templates. 132000 means the parameter count does not match the template body. Count the {{1}} placeholders in the WhatsApp Manager and pass exactly that many to body(), in order.

The webhook returns 403 for every request

Either app_secret is empty โ€” which is refused, never trusted โ€” or the signature does not match the body. The usual cause of a mismatch is middleware that modifies the request body before this package sees it, since the HMAC is computed over the raw bytes. Keep webhook.middleware minimal.

For the handshake: Meta sends hub.mode, and PHP turns the dot into an underscore before it reaches the query bag. That is handled โ€” but it is worth knowing if you are debugging with a hand-made request.

A listener runs twice for one inbound message

Meta redelivers on any non-2xx and occasionally duplicates on success. Ids are remembered through the cache โ€” but the array store cannot share them across processes, so set webhook.idempotency.store to a shared store in production.

MissingDependencyException about QR codes

composer require bacon/bacon-qr-code

It is a suggested dependency: a package that builds links should not drag a rendering library into every application that installs it.

Security

What this package defends against, what it does not, and how to report something it got wrong.

The trade

A click-to-chat link is public by construction: everything prefilled into it travels in a URL, through the user's browser, and may end up in history, logs and referrer headers. Do not prefill a secret. That is the trade at the heart of the link half, and no amount of care in this package changes it.

VersionSupported
2.xYes
1.xNo โ€” upgrade, see Upgrading

What it defends against

  • Webhook forgery. The HMAC is computed over the raw request body and compared with hash_equals(). An empty app_secret is a failure, not a waiver, and the rejection is a bare 403 that reveals nothing.
  • Accidental exposure of the webhook. The route does not exist until webhook.enabled is explicitly true. Installing or updating the package cannot add a public endpoint to your application.
  • Leaking message content. Nothing is logged unless a channel is named in logging.channel, and even then bodies are redacted by default. Inbound message text is personal data, and in many deployments special-category data.
  • Leaking credentials. whatsapp:status masks the token, and exceptions are built from the parsed error body rather than the HTTP client's request context, which carries the Authorization header.
  • Injection into a rendered page. toHtml() escapes before adding tags, and the Blade component escapes both URL and label.
  • Unbounded bodies. A body is bounded before it becomes a URL, so a value reaching a link from a request cannot produce an unbounded one.

Scope

In scope: a webhook request accepted without a valid signature, an access token or phone number appearing in a log line or an exception message, an inbound message body producing markup that escapes into a rendered page, a crafted phone number or message body causing this package to address a conversation other than the one asked for, and any crash reachable from a value an application would reasonably pass in.

Out of scope: how an application stores the numbers and message bodies it passes here, and anything decided by Meta's platform rather than by this code.

It cannot tell you whether a number belongs to the person you think it does, nor whether it has WhatsApp at all. It validates shape, not identity.

Reporting a vulnerability

Email gabriele@sbaiz.com with a description, the package version, and a reproduction if you have one. Please do not open a public issue.

You will get an acknowledgement within 5 working days and an assessment within 15. If the report is valid you will be credited in the release notes unless you ask not to be.

Upgrading from 1.x

Version 2 is a rewrite, but the changes at a call site are small and mechanical.

The API

// 1.x
WhatsappToolkit::url($phone, $html);
WhatsappToolkit::formatMessage($html);      // returned url-encoded text
WhatsappToolkit::formatPhoneNumber($phone); // returned url-encoded text

// 2.0
WhatsappToolkit::url($phone, $html);        // unchanged, still the shortcut
WhatsappToolkit::to($phone)->html($html)->url();
WhatsappToolkit::format($html);             // plain WhatsApp markup
WhatsappToolkit::number($phone)->waId();    // a value object

url() survives with the same signature, so the most common call site needs no change โ€” but what it produces is different.

What the output looks like now

ChangeWasIs
Numbers normalisedphone=%2B39+333+123+4567wa.me/393331234567
Default flavourapi.whatsapp.comwa.me
Entities&amp; &eacute;& รฉ
Emphasis" *Bold* text""*Bold* text"
Second <ol>4. 5.1. 2.
Links"here""here (https://โ€ฆ)"
Spaces+%20
If your application prepends a country code by hand โ€” '+39' . $phone โ€” delete that and set WHATSAPP_COUNTRY_CODE=39 instead. To keep the 1.x URL exactly, set link.target to LinkTarget::Api.

Formatting no longer encodes

// 1.x
$encoded = WhatsappToolkit::formatMessage($html);

// 2.0
$text = WhatsappToolkit::format($html);   // plain
$encoded = rawurlencode($text);            // only if you really need it

number() throws InvalidPhoneNumberException on something unusable; use tryNumber() for a null instead.

Hand-written links in Blade

// before
<a href="https://api.whatsapp.com/send?phone={{ $phone }}&text={{ urlencode($subject) }}"
   target="_blank">Chat on WhatsApp</a>

// after
<x-whatsapp-link :to="$phone" :message="$subject">Chat on WhatsApp</x-whatsapp-link>

Requirements and removals

 1.x2.0
PHP8.08.3
Laravel10, 11, 1211, 12, 13
  • formatMessage() โ†’ format()
  • formatPhoneNumber() โ†’ number()
  • The empty database/factories skeleton is gone

Changelog

All notable changes to whatsapp-toolkit.

2.0.0

A rewrite. The migration is short โ€” see Upgrading from 1.x.

Fixed

  • Phone numbers are normalised rather than url-encoded. +39 333 123 4567 travelled as %2B39+333+123+4567 and opened a chat with nobody; it is now 393331234567.
  • HTML entities are decoded. &amp;, &egrave; and &#39; no longer reach the chat window literally.
  • Ordered lists restart at 1. A second <ol> continued the first one’s numbering.
  • Links keep their destination. <a href="…">here</a> lost its URL entirely.
  • Emphasis markers no longer carry stray padding spaces.
  • Spaces encode as %20, not as a + that some clients render literally.

Added

  • A fluent, immutable Chat builder, and five link flavours.
  • A PhoneNumber value object with waId(), e164(), national() and a configurable default country code.
  • Markdown → WhatsApp and WhatsApp → HTML formatters.
  • A <x-whatsapp-link> Blade component and a @whatsappUrl directive.
  • A WhatsappNumber validation rule, an AsWhatsappNumber cast, and Str/Stringable macros.
  • QR codes, via the suggested bacon/bacon-qr-code.
  • Named message templates in config, with :placeholder substitution.
  • A Meta Cloud API client — text, template, media, interactive, location and reaction messages, media upload and download, typed responses, and exceptions mapped from Meta’s error codes with a hint naming the actual fix.
  • A whatsapp notification channel and WhatsappMessage payload.
  • WhatsappToolkit::fake() with assertSent(), assertSentTo(), assertNothingSent() and friends.
  • An opt-in inbound webhook with signature verification, idempotent delivery and five events.
  • A Nova action for sending from a detail screen, when Nova is installed.
  • whatsapp:url and whatsapp:status commands.
  • A config file, contracts for every replaceable piece, and container bindings.
  • CI: tests across PHP 8.3/8.4 and Laravel 11/12, PHPStan level 6, Pint.

Changed

  • Requires PHP 8.3 and Laravel 11, 12 or 13.
  • Formatting and encoding are separate: format() returns plain WhatsApp markup, and percent-escaping happens once inside url().
  • wa.me is the default link flavour.
  • The static API is gone; the package is an object bound in the container.

Performance

Measured by composer bench against the 1.x implementation: plain text 31× faster, small HTML 1.9×, rich editor output 1.4×, and a repeated body 230× through the per-request memo.

1.3.0 and earlier

Initial releases: URL generator, HTML message formatter, phone number formatter.

Project & licence

How to contribute, how the suite is run, who wrote it, and the licence in full.

Setup and the three gates

git clone git@github.com:gabrielesbaiz/whatsapp-toolkit.git
cd whatsapp-toolkit
composer install
composer format    # pint, laravel preset plus strict types
composer analyse   # phpstan level 6 via larastan
composer test      # pest โ€” 183 tests, 264 assertions
composer bench     # the formatter, against the 1.x implementation

CI runs the first three on PHP 8.3 and 8.4 against Laravel 11 and 12, at both lowest and stable dependencies. composer lint runs format and analyse together.

Ground rules

  • Every behaviour change needs a test that fails without it. The suite runs in about two seconds; there is no excuse.
  • The link layer must stay dependency-free. Building a URL may not need an HTTP client, a credential or a network. An architecture test enforces this, and it is not negotiable โ€” a great many applications install this package for the links alone.
  • Formatting is not encoding. format() returns plain WhatsApp markup; percent-escaping happens once, in Chat::url().
  • Performance claims come with a measurement. Touching HtmlFormatter means running composer bench before and after and putting the numbers in the PR.
  • Comments explain why, not what. If a line is surprising, say what it prevents.

Working on the Cloud API layer

The whole sending half must stay dormant when it is not configured: no routes, no credential reads, no exceptions on boot. Tests use two different fakes, and the difference matters:

  • WhatsappToolkit::fake() records message objects and never touches HTTP. Use it when the test is about behaviour โ€” what was sent, to whom.
  • Http::fake() intercepts real requests. Use it when the test is about the wire format, the retry policy, or error mapping.
Never commit a real token, phone number id or webhook payload from a live account โ€” the fixtures in tests/ are deliberately fictional.

Credits

Written and maintained by Gabriele Sbaiz, with thanks to everyone who has contributed.

It stands on work this package does not contain: Laravel, spatie/laravel-package-tools, and โ€” when you ask for a QR code โ€” bacon/bacon-qr-code.

Licence

MIT. The warranty disclaimer and limitation of liability apply in full.

The MIT License (MIT) Copyright (c) Gabriele Sbaiz <gabriele@sbaiz.com> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
This package is provided as is. It builds links and formats text; it cannot tell you whether a number belongs to the person you think it does, or whether it has WhatsApp at all. The Cloud API half depends entirely on Meta's platform โ€” its rate limits, template approvals, 24-hour window and error codes are theirs, not this package's, and they change without notice.