teodoriu/laravel-whatsapp
A Laravel package for integrating WhatsApp messaging into your application, with a simple API for sending messages, handling sessions, and managing webhook events so you can wire WhatsApp notifications into existing PHP workflows.
Install and pin. This is a 4-star, single-maintainer package sitting on a customer-facing path — pin the exact version in composer.json rather than floating on ^1.1, and commit composer.lock.
composer require teodoriu/laravel-whatsapp:1.1.0
php artisan vendor:publish --tag=whatsapp-config # tag name may differ; check README
php artisan config:cache
Where to look first, in this order:
config/whatsapp.php after publishing — this is the real API surface. It tells you the env var names, whether the Graph API version is configurable, and whether phone_number_id is global or per-connection. Trust this file over the README.README.md — the description promises a fluent interface, notification support, and webhooks, but gives no method signatures. Confirm the actual class names before writing code against them.src/ — the package is small enough to read end-to-end in one sitting. Do it. Specifically check: how access tokens are attached, whether HTTP calls go through Illuminate\Support\Facades\Http (this determines whether Http::fake() works in your tests, and it almost certainly does), and how errors are surfaced (exception vs. response object).CHANGELOG / the v1.1.0 diff — the release notes are a bare link to a compare URL with no feature list or migration notes. A minor bump implies additive-only, but read the diff before upgrading from 1.0.9.First use case: send one pre-approved template.
// .env — names per your published config
WHATSAPP_TOKEN=EAAG... // system-user token, not the 24h temporary one
WHATSAPP_PHONE_NUMBER_ID=1234567890
WHATSAPP_VERIFY_TOKEN=some-random-string
WHATSAPP_API_VERSION=v21.0
// tinker
app(WhatsApp::class)->send(/* template message, per README */);
Meta rejects business-initiated free-form text outside the 24-hour customer service window, so a template (the default hello_world is fine for smoke-testing) is the correct first request. Log the returned wamid — you will need it later.
Wrap the send call behind your own interface on day one. This is the single highest-leverage decision given the maintenance risk. It costs an hour and makes the package swappable.
interface WhatsAppChannelContract {
public function sendTemplate(string $to, string $template, array $params, string $lang = 'en_US'): string; // returns wamid
public function sendText(string $to, string $body): string;
}
final class PackageWhatsAppChannel implements WhatsAppChannelContract { /* delegates */ }
// bind in a service provider
$this->app->bind(WhatsAppChannelContract::class, PackageWhatsAppChannel::class);
Drive it through Laravel Notifications. This is the idiomatic fit and the reason to prefer this package over raw HTTP. Implement toWhatsApp() on a Notification class, register the channel in via(), and you get queueing, retries, and ShouldQueue backoff for free — plus you can add an email/SMS fallback channel in the same via() array.
final class OrderShipped extends Notification implements ShouldQueue {
public function via($notifiable): array { return ['whatsapp']; }
public function toWhatsApp($notifiable) {
return WhatsAppMessage::template('order_shipped')
->to($notifiable->phone_e164)
->parameters([$notifiable->order->tracking_number]);
}
}
Keep the phone number as a separate, normalized column (+ stripped, E.164, country code included). Don't reuse a free-text phone field that users typed in.
Webhooks: split verification from processing. Meta's GET handshake must echo hub_challenge when hub_verify_token matches. The POST payload must be acknowledged with a 200 fast — Meta retries aggressively on non-2xx and will disable the webhook after sustained failures. So:
Route::get('/webhooks/whatsapp', [WebhookController::class, 'verify']);
Route::post('/webhooks/whatsapp', [WebhookController::class, 'receive']);
public function receive(Request $request) {
DB::table('whatsapp_webhook_events')->insertOrIgnore([ // dedupe on message id
'external_id' => $request->input('entry.0.changes.0.value.messages.0.id'),
'payload' => json_encode($request->all()),
]);
ProcessWhatsAppWebhook::dispatch($request->all())->onQueue('webhooks');
return response()->noContent();
}
Persist-then-dispatch gives you replayability when your handler has a bug, and the insertOrIgnore on the wamid protects against the duplicate deliveries Meta makes routinely.
Test with Http::fake(). Because the package likely uses Laravel's HTTP client, you can assert the outbound payload shape without hitting the network:
Http::fake(['graph.facebook.com/*' => Http::response(['messages' => [['id' => 'wamid.test']]], 200)]);
Assert on Http::assertSent() rather than on the package's return value where possible — it keeps your tests honest about what actually goes over the wire.
sendText() wrapper so it throws loudly rather than silently failing, and route anything time-delayed through templates. This is the pitfall that burns teams in week two.order_shipped / en_US and en are different keys. Pin language codes in config, not scattered across call sites.Authorization headers in your HTTP logging middleware.config:cache kills env() calls. If the package reads env() outside the config file, your cached deploy will break in a way that's invisible locally. Grep src/ for env( before shipping.code, subcode, error_data.details) — some are permanent (invalid number, template not found, unapproved template) and some transient (rate limit, 500). Wrap sends and classify: retry transient, dead-letter permanent, and never let a bad recipient break the whole queue batch./media first and referencing the returned media id, or passing a public HTTPS URL. Don't assume a simple attach() exists.X-Hub-Signature-256. Check whether the package verifies it; if not, add middleware that does, using your app secret. An unverified webhook endpoint is an open write path into your app.WhatsAppChannelContract implementation is cleaner than forking.via(), and monitor for abandoned-package signals quarterly. If WhatsApp becomes revenue-critical, revisit with a supported BSP — the interface wrapper makes that a swap, not a rewrite.How can I help you explore Laravel packages today?