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.
quote Q-7 is ready stop
Cafรฉ & croissant stop
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
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.
| Input | waId() | Why |
|---|---|---|
| +39 333 123 4567 | 393331234567 | Punctuation stripped |
| (+39) 333/123-4567 | 393331234567 | Same |
| 0039 333 1234567 | 393331234567 | 00 is an access prefix |
| 333 123 4567 | 393331234567 | Country code applied |
| 039 2345678 | 39392345678 | Monza landline: 0 is a trunk prefix |
| +1 (202) 555-0147 | 12025550147 | Carries its own country code |
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> & co.</p>'); WhatsappToolkit::formatMarkdown('**Hi** *Sarah*'); WhatsappToolkit::toHtml('*Hi* _Sarah_');
Output
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>
After โ the component
<x-whatsapp-link :to="$dealer->mobile_phone" :message="$body" class="btn"> Chat on WhatsApp </x-whatsapp-link>
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.
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.
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.
per month ยท cancel anytime
♥ Become a sponsorCompany tiers get a logo in the README and on this page.
Star the repo
Thirty seconds, and it is the first signal other developers look at.
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.
Installation
One composer line, one env value, and links work. Everything else is optional.
Requirements
| PHP | 8.3 or newer |
| Laravel | 11, 12 or 13 |
| bacon/bacon-qr-code | Suggested โ only for QR codes |
| laravel/nova | Suggested โ only for the bundled action |
| Meta WhatsApp Business account | Only 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
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>"
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...
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.
Building a link
use Gabrielesbaiz\WhatsappToolkit\Facades\WhatsappToolkit; WhatsappToolkit::to('+39 333 123 4567')->text('Hello')->url(); WhatsappToolkit::to('333 123 4567')->html('<p>Hi <b>Sarah</b></p>')->url(); WhatsappToolkit::to($dealer->mobile_phone)->markdown('**Quote** ready')->url(); // the one-liner, when a call site wants nothing but a URL WhatsappToolkit::url($dealer->mobile_phone, $body);
Terminal methods are url(), link(),
qr(), qrDataUri(), send(), message(),
toArray(), and casting to string, which gives the URL.
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
| Flavour | Produces | When |
|---|---|---|
| WaMe | https://wa.me/393331234567?text=โฆ | The default. Shortest URL, sparsest QR |
| Api | https://api.whatsapp.com/send?phone=โฆ | What 1.x produced |
| Web | https://web.whatsapp.com/send?phone=โฆ | Desktop-only back office |
| Deep | whatsapp://send?phone=โฆ | Opens the app, never a browser |
| Business | https://wa.me/message/ABCDE12345 | A 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>
| Attribute | Default | What it does |
|---|---|---|
| to | โ | The recipient, in any format |
| message | null | The prefilled body |
| format | html | html, markdown or text |
| target | config | wa.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]; }
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
| HTML | Becomes | HTML | Becomes |
|---|---|---|---|
| <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 é arrives as รฉ
rather than literally โ while markup that was escaped in the source stays escaped:
<b> 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.
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.
Numbers, links and formatting
| Key | Default | What it does |
|---|---|---|
| default_country_code | '39' | Applied to bare local numbers. null refuses them instead |
| link.target | LinkTarget::WaMe | Which link flavour is built |
| link.max_length | 4096 | The body ceiling, in characters |
| link.on_overflow | 'truncate' | truncate, throw or ignore |
| format.memo | true | Memoise formatted bodies for the request |
| format.memo_size | 128 | How many to keep |
| format.links | 'append' | append keeps label (url); strip keeps only the label |
| templates | [] | Named Markdown bodies with :placeholders |
| qr.size | 300 | Pixels |
| qr.margin | 2 | Quiet zone, in modules |
| qr.error_correction | 'M' | L, M, Q or H |
on_overflow decides what happens at
max_length: cut at a word boundary, refuse loudly, or let it through.Cloud API
| Key | Default | What it does |
|---|---|---|
| cloud.enabled | false | The whole sending layer, on or off |
| cloud.phone_number_id | null | Your business number's id |
| cloud.business_account_id | null | Your WABA id |
| cloud.access_token | null | System User token in production |
| cloud.api_version | 'v21.0' | Graph API version |
| cloud.base_url | graph.facebook.com | Point at a sandbox if you have one |
| cloud.timeout | 10 | Seconds |
| cloud.connect_timeout | 5 | Seconds |
| cloud.retry.times | 3 | Attempts, for transient failures only |
| cloud.retry.sleep | 200 | Backoff base, milliseconds |
| cloud.retry.max | 5000 | Backoff 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
| Key | Default | What it does |
|---|---|---|
| webhook.enabled | false | Whether the inbound route exists at all |
| webhook.path | 'whatsapp/webhook' | Where it lives |
| webhook.domain | null | Restrict it to one domain |
| webhook.middleware | ['api'] | Keep this minimal |
| webhook.verify_token | null | Your half of the subscription handshake |
| webhook.app_secret | null | Required โ an empty one rejects every request |
| webhook.idempotency.enabled | true | Drop repeat deliveries |
| webhook.idempotency.store | null | Use a shared store in production |
| webhook.idempotency.ttl | 86400 | How long ids are remembered |
| logging.channel | null | null means nothing is logged |
| logging.redact_content | true | Keep message bodies out of logs |
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
| Command | What it does |
|---|---|
| whatsapp:url {phone} {message?} | Print the formatted body and the link |
| whatsapp:url --target= | wa.me, api, web, deep or business |
| whatsapp:status | Show 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
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
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
| Method | Returns | What it does |
|---|---|---|
| to($recipient) | Chat | Start a chat addressed to a number |
| chat() | Chat | Start one with no recipient yet |
| number($value) | PhoneNumber | Normalise, or throw |
| tryNumber($value) | ?PhoneNumber | Normalise, or null |
| format($html) | string | HTML โ WhatsApp markup |
| formatMarkdown($md) | string | Markdown โ WhatsApp markup |
| toHtml($text) | string | WhatsApp markup โ escaped HTML |
| formatAs($body, $format) | string | Either, by MessageFormat |
| url($recipient, $html = null) | string | The one-liner |
| renderTemplate($name, $vals = []) | string | Fill a config template |
| cloud() | CloudApi | The Cloud API client |
| fake() | CloudApiFake | Swap the client for a recorder |
| qr() | QrRenderer | The QR renderer |
| defaultTarget() | LinkTarget | The configured link flavour |
| maxLength() | int | The configured body ceiling |
| overflowStrategy() | string | truncate, throw or ignore |
Chat
Every setter returns a new instance.
| Method | Returns | What it does |
|---|---|---|
| to($recipient) | Chat | Address it; a string or a PhoneNumber |
| shortCode($code) | Chat | Address a business short link instead |
| text($body) | Chat | A body already in WhatsApp markup |
| html($body) | Chat | A body from a rich-text editor |
| markdown($body) | Chat | A body in Markdown |
| template($name, $vals = []) | Chat | A body from config |
| target($target) | Chat | Override the link flavour |
| truncate($limit) | Chat | Cut the body at a word boundary |
| withoutPreview() | Chat | Cloud API: no link preview card |
| message() | Message | The formatted body |
| url($target = null) | string | The click-to-chat URL |
| link($label, $attributes = []) | HtmlString | An escaped <a> |
| qr($size = null) | HtmlString | An SVG QR code |
| qrDataUri($size = null) | string | The same, as a data URI |
| send() | MessageResponse | Deliver it through the Cloud API |
| recipient() | ?PhoneNumber | Who it is addressed to |
| toArray() | array | ['to', 'text', 'url'] |
PhoneNumber and Message
| Method | Returns | What it does |
|---|---|---|
| PhoneNumber::parse($v, $default = null) | PhoneNumber | Normalise, or throw. '' refuses local numbers |
| PhoneNumber::tryParse($v, $default = null) | ?PhoneNumber | Normalise, or null |
| PhoneNumber::fromWaId($waId) | PhoneNumber | Wrap a canonical id without re-deriving it |
| waId() | string | Digits only โ what WhatsApp dials |
| e164() | string | + and digits โ what to store |
| national() | string | Without the country code |
| equals($other) | bool | Same number |
| Message::length() | int | Characters, counted with mb_strlen() |
| Message::truncate($limit, $ellipsis) | Message | Cut at a word boundary |
| Message::encoded() | string | rawurlencoded, for a query string |
CloudApi and messages
| Method | Returns | What it does |
|---|---|---|
| send($message, $to = null) | MessageResponse | Send one message |
| sendMany($messages) | array | Send several, in order |
| markRead($messageId) | bool | Mark an inbound message read |
| uploadMedia($path, $mime = null) | MediaResponse | Upload a file, get an id |
| mediaUrl($mediaId) | MediaResponse | Look one up |
| downloadMedia($mediaId) | string | Fetch the bytes |
| post($endpoint, $payload) | array | Escape hatch for unmodelled endpoints |
| usingPhoneNumberId($id) | static | Send from another business number |
| Message class | Built 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
| Assertion | Passes 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
| Event | Dispatched when |
|---|---|
| WhatsappMessageReceived | Someone wrote to your business number |
| WhatsappStatusUpdated | A message you sent was delivered or read |
| WhatsappMessageFailed | A message you sent could not be delivered |
| WhatsappWebhookReceived | Every verified payload, before it is split |
| WhatsappMessageSent | On the way out, when a send succeeds |
| Meta codes | Exception |
|---|---|
| 0, 3, 10, 190, 368 | AuthenticationException |
| 4, 130429, 131048, 131056 | RateLimitException |
| 131047 | ReEngagementRequiredException |
| 131026, 131030, 131051, 133010 | RecipientException |
| 131052, 131053 | MediaException |
| 132000โ132016 | TemplateException |
| 1, 2, 5xx | TransientException |
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 é or & 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.
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.
| Version | Supported |
|---|---|
| 2.x | Yes |
| 1.x | No โ upgrade, see Upgrading |
What it defends against
- Webhook forgery. The HMAC is computed over the raw request body and
compared with
hash_equals(). An emptyapp_secretis 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.enabledis 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:statusmasks the token, and exceptions are built from the parsed error body rather than the HTTP client's request context, which carries theAuthorizationheader. - 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.
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
| Change | Was | Is |
|---|---|---|
| Numbers normalised | phone=%2B39+333+123+4567 | wa.me/393331234567 |
| Default flavour | api.whatsapp.com | wa.me |
| Entities | & é | & รฉ |
| Emphasis | " *Bold* text" | "*Bold* text" |
| Second <ol> | 4. 5. | 1. 2. |
| Links | "here" | "here (https://โฆ)" |
| Spaces | + | %20 |
'+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.x | 2.0 | |
|---|---|---|
| PHP | 8.0 | 8.3 |
| Laravel | 10, 11, 12 | 11, 12, 13 |
formatMessage()โformat()formatPhoneNumber()โnumber()- The empty
database/factoriesskeleton 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 4567travelled as%2B39+333+123+4567and opened a chat with nobody; it is now393331234567. - HTML entities are decoded.
&,èand'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
Chatbuilder, and five link flavours. - A
PhoneNumbervalue object withwaId(),e164(),national()and a configurable default country code. - Markdown → WhatsApp and WhatsApp → HTML formatters.
- A
<x-whatsapp-link>Blade component and a@whatsappUrldirective. - A
WhatsappNumbervalidation rule, anAsWhatsappNumbercast, andStr/Stringablemacros. - QR codes, via the suggested
bacon/bacon-qr-code. - Named message templates in config, with
:placeholdersubstitution. - 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
whatsappnotification channel andWhatsappMessagepayload. WhatsappToolkit::fake()withassertSent(),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:urlandwhatsapp:statuscommands.- 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 insideurl(). wa.meis 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, inChat::url(). - Performance claims come with a measurement. Touching
HtmlFormattermeans runningcomposer benchbefore 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.
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.
Sponsor
Free forever, and not free to maintain.
What sponsorship buys
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.
Four ways to help
| Way | Cost | Why it helps |
|---|---|---|
| โญ Star the repo | Free | Thirty seconds, and it is the first signal other developers look at |
| ๐ Open a good issue | Free | A phone format that normalises wrong, with the input, is worth more than you think |
| โค๏ธ Sponsor from $5 | $5/month | Pays for the compatibility work nobody sees |
| ๐ข Company tier | Custom | Your logo in the README and on this site, and a direct line for upgrade questions |