scriptdevelop/whatsapp-manager
scriptdevelop/whatsapp-manager is a Laravel/PHP package that helps developers add and manage WhatsApp messaging in their applications, offering a convenient integration layer for message sending, contact or conversation handling, and related WhatsApp workflows.
Install and publish
composer require scriptdevelop/whatsapp-manager
php artisan vendor:publish --provider="ScriptDevelop\WhatsAppManager\WhatsAppServiceProvider"
Publishing typically gives you a config/whatsapp.php (or similarly named) plus optional migrations for message/conversation logging. Run php artisan migrate only if the package ships persistence you actually want; many teams prefer to store their own message records and skip the package tables.
Configure via .env, never hardcode
Expect to fill in at minimum:
WHATSAPP_API_TOKEN=EAAG... # temporary or System User token
WHATSAPP_PHONE_NUMBER_ID=1234567890 # the sender, not your personal number
WHATSAPP_BUSINESS_ACCOUNT_ID=... # needed for templates/media management
WHATSAPP_VERIFY_TOKEN=some-random-string
WHATSAPP_API_VERSION=v20.0
Then php artisan config:cache in deployed environments and confirm the values survive — a stale cached config is the single most common "it works locally" failure with this class of package.
Where to look first
config/whatsapp.php — endpoint base URL, API version, timeout, and which credentials map to which Graph API field.src/ for the client class and any Events/ or Jobs/ directory. The README is short on this repo; the source is the real documentation.First use case: send yourself a text
use ScriptDevelop\WhatsAppManager\Facades\WhatsApp;
WhatsApp::sendMessage(
to: '5215512345678', // E.164 digits, no "+", no spaces
type: 'text',
text: 'Hello from Laravel'
);
Verify method signatures against src/ before wiring this into a controller — this package is low-traffic (80 stars, no dependents) and its public API has likely drifted from the examples you'll find online.
Second step: register the webhook. In the Meta App dashboard set the callback URL to https://your-app.test/webhooks/whatsapp (or whatever the package route is) and the verify token to your WHATSAPP_VERIFY_TOKEN. Meta's verification challenge must return the raw hub_challenge value — not JSON.
Inject the client, don't sprinkle the facade
Facades are fine in controllers and jobs, but for anything you want to test, resolve the underlying client through the container:
public function __construct(private WhatsAppClient $client) {}
Then bind a fake in tests. If the package only exposes a facade, consider wrapping it in your own thin WhatsAppGateway interface so your domain code isn't coupled to a low-star dependency you may need to replace.
Treat outbound sends as queued jobs
Graph API latency and rate limiting will bite you in a request cycle. Wrap sends:
SendWhatsAppMessage::dispatch($recipient, $payload)
->onQueue('whatsapp')
->delay(now()->addSeconds(2));
Give the job $tries = 3, $backoff = [5, 30, 120], and a failed() handler that records the Graph error code. Separate the queue from your default so a WhatsApp outage doesn't stall other work.
Webhook controller should do almost nothing
Pattern that holds up:
public function __invoke(Request $request)
{
// 1. verify signature (X-Hub-Signature-256) against the raw body
// 2. return 200 immediately
// 3. dispatch each message to a job
return response()->noContent();
}
Meta retries aggressively when you're slow or return non-2xx; a synchronous handler that calls the Graph API or hits a slow DB will cause duplicate message storms. Store the message.id and dedupe on it.
Use an event → listener split for inbound
Normalize the webhook into your own event (InboundWhatsAppMessage) with your own DTO. The package's payload shape is Meta's, which is deeply nested and changes with API versions. Keeping Meta's JSON at the edge makes upgrades survivable.
Multi-tenant / multi-number
If you serve several businesses, don't rely on the singleton config. Keep a whatsapp_accounts table (phone_number_id, token, waba_id) and construct the client per tenant, or set config at runtime before resolving. If the package caches the client as a singleton, you'll leak credentials between tenants — check the service provider bindings carefully.
Templates are a separate workflow
Sending outside the 24-hour window requires a pre-approved template. Build a small admin surface that lists templates from the WABA (GET /{waba-id}/message_templates) and caches them; don't hardcode template names in code, since marketing will rename them.
Local development
Expose your app with ngrok or expose and re-point the Meta webhook URL each session, or use a permanent tunnel. Keep a whatsapp:simulate artisan command that POSTs a captured Meta payload to your own webhook route so you can iterate without burning real conversations.
Testing
Http::fake([
'graph.facebook.com/*' => Http::response(['messages' => [['id' => 'wamid.TEST']]], 200),
]);
Assert the outgoing JSON shape (recipient, messaging_product: whatsapp, type, template name/components) rather than just that a request was made. Webhook tests should use a captured real payload fixture — build them from Meta's docs examples, including a status-only payload (value.statuses), which is easy to forget.
The 24-hour window is the whole game. Free-form text only works within 24 hours of the user's last message. Outside it, the API returns an error and you must use a template. Design your UX around this; don't discover it in production.
Tokens expire. Temporary tokens die in ~24 hours. Use a System User token with a long expiry or implement refresh. Also, a token is scoped to specific apps/WABAs — a "valid" token that returns 401 on a different phone_number_id is a permissions issue, not a bug in the package.
Signature verification is not optional. Validate X-Hub-Signature-256 with hash_hmac('sha256', $rawBody, $appSecret) and hash_equals. Use $request->getContent() (raw body), not $request->all() — re-encoded JSON breaks the HMAC. If the package's webhook route skips this, add your own middleware.
Verification handshake returns plain text. Meta sends hub.mode=subscribe, hub.verify_token, hub.challenge. Echo the challenge as a raw string with 200. Returning JSON, or comparing tokens case-insensitively, fails silently in the dashboard.
Phone number format. E.164 digits only, no +, no leading zeros, no formatting. Store the number you received in the webhook (wa_id) verbatim and use that for replies.
Config cache traps. config:cache during deploy, and remember env() calls outside config files return null when cached. If the package reads env() directly in src/, that's a bug you'll have to work around by setting config values explicitly.
Media is link-or-id, not multipart. You either send a public HTTPS link or upload first and send the returned media id. Links must be publicly reachable — Meta fetches them server-side, so localhost and signed URLs that expire in 30 seconds will fail. For anything user-generated, upload and store the media id.
Idempotency and duplicate sends. Meta retries webhooks. Dedupe on messages[0].id. For outbound, log the returned wamid so you can reconcile delivery status later — the statuses webhook is your only source of truth for delivered/read.
Error handling needs the Graph error code, not the HTTP status. 400 covers everything from malformed numbers to unapproved templates. Catch the response body, extract error.code / error.error_subcode (e.g. 131026 undeliverable, 132000 template param mismatch), and branch on those. Log a redacted payload — never log the bearer token.
Rate limits are per phone number. Throughput is capped and tier-based. Keep the queue worker concurrency modest and add jitter; hammering the API gets you throttled and can affect your quality rating, which affects your messaging tier.
Version drift is real. This is an 80-star package with zero dependents. Pin an exact version in composer.json, read the changelog before upgrading, and keep your wrapper interface thin. Meta's Graph API version in the config will need bumping independently of the package — verify the package doesn't hardcode paths that break on new versions.
Extension points to look for: a publishable config, a bindable HTTP client (so you can inject Guzzle middleware for retries/logging), Laravel events for inbound messages, and any macroable trait on the facade. If none exist, don't fork — wrap. A fork of an unmaintained package is a permanent tax; a 100-line adapter is not.
Observability. Log every outbound send with to, type, wamid, and latency; log every inbound with message.id and type. When WhatsApp support asks for a wamid, you want it in one grep.
How can I help you explore Laravel packages today?