A arquitetura em uma frase
Nada de chamar a API no meio do controller. O fluxo que funciona bem em produção tem quatro peças, todas nativas do Laravel: configuração centralizada, um cliente HTTP pré-configurado, Jobs para o envio e para o processamento do que chega, e uma Notification para o código de negócio não precisar saber que existe uma API de WhatsApp por trás. Não há pacote oficial para PHP; tudo abaixo usa o Illuminate\Http que vem com o framework.
Credenciais em config/services.php
Siga o mesmo padrão usado para Mailgun ou Stripe. As chaves ficam no .env e o código lê sempre via config(), o que mantém o php artisan config:cache funcionando.
// config/services.php
'dapi' => [
'key' => env('DAPI_API_KEY'),
'session' => env('DAPI_SESSION_ID'),
'webhook_token' => env('DAPI_WEBHOOK_TOKEN'),
],Em seguida, um macro no AppServiceProvider entrega um cliente pronto, com URL base, header de autenticação sem Bearer e timeouts explícitos. Assim nenhum ponto do código esquece o timeout.
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Http;
public function boot(): void
{
Http::macro('dapi', fn () => Http::baseUrl('https://api.d-api.cloud')
->withHeaders(['Authorization' => config('services.dapi.key')])
->acceptJson()
->connectTimeout(3)
->timeout(10));
}O Job que envia a mensagem
O Job decide o que é falha temporária e o que é erro definitivo. Resposta 4xx significa que o pedido está errado (número inválido, sessão desconectada, campo faltando) e tentar de novo não muda nada. Erro 5xx e timeout podem passar sozinhos, então o Job relança a exceção e o worker agenda outra tentativa usando o $backoff.
// app/Jobs/EnviarWhatsApp.php
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class EnviarWhatsApp implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public array $backoff = [10, 30, 60, 120];
public function __construct(
public string $to,
public string $text,
public ?string $sessionId = null,
) {}
public function handle(): void
{
$response = Http::dapi()->post('/api/v1/messages/send/text', [
'sessionId' => $this->sessionId ?? config('services.dapi.session'),
'to' => $this->to,
'text' => $this->text,
]);
if ($response->clientError()) {
// corpo de erro: { success: false, error, statusCode }
$this->fail(new RuntimeException($response->json('error', 'Requisição recusada')));
return;
}
$response->throw(); // 5xx vira exceção e o worker tenta de novo
}
}Um cuidado honesto: quando o timeout estoura, você não sabe se a mensagem saiu. A nova tentativa pode gerar duplicidade. Para avisos em que isso importa, como cobrança, reduza $tries ou registre o envio antes de repetir. Para quem dispara avisos de pedido, pagamento e entrega, o guia de notificações transacionais aprofunda esse ponto.
Um canal de Notification para WhatsApp
É aqui que a integração fica com cara de Laravel. Com um canal próprio, qualquer Notification pode incluir o WhatsApp no via(), ao lado de mail e database.
// app/Notifications/Channels/WhatsAppChannel.php
namespace App\Notifications\Channels;
use App\Jobs\EnviarWhatsApp;
use Illuminate\Notifications\Notification;
class WhatsAppChannel
{
public function send(object $notifiable, Notification $notification): void
{
$numero = $notifiable->routeNotificationFor('whatsapp', $notification);
if (! $numero) {
return;
}
EnviarWhatsApp::dispatch($numero, $notification->toWhatsApp($notifiable));
}
}
// app/Notifications/PedidoEnviado.php
public function via(object $notifiable): array
{
return ['mail', WhatsAppChannel::class];
}
public function toWhatsApp(object $notifiable): string
{
return "Olá, {$notifiable->name}! Seu pedido {$this->pedido->codigo} saiu para entrega.";
}
// app/Models/User.php
public function routeNotificationForWhatsApp(): ?string
{
return $this->whatsapp; // formato 5511999999999
}O código de negócio continua limpo: $user->notify(new PedidoEnviado($pedido)). Quem decide o canal é a Notification, e quem conhece a API é só o Job.
Esse desenho também facilita regras que costumam aparecer depois: respeitar a preferência de contato do usuário (basta checar um campo dentro do via()), evitar mensagens de madrugada atrasando o dispatch com delay() ou mandar um texto diferente para cada perfil. Nenhuma dessas mudanças toca no Job nem na configuração do cliente HTTP.
Rota e controller do webhook
O webhook entra por routes/api.php, fora do middleware de CSRF. No Laravel 11 em diante esse arquivo é criado com php artisan install:api. O controller valida o token secreto da URL, filtra o evento e despacha um Job. Nada de consulta pesada antes de responder.
// routes/api.php
Route::post('/webhooks/whatsapp/{token}', WhatsAppWebhookController::class);
// app/Http/Controllers/WhatsAppWebhookController.php
class WhatsAppWebhookController
{
public function __invoke(Request $request, string $token): JsonResponse
{
abort_unless(hash_equals(config('services.dapi.webhook_token'), $token), 401);
if ($request->input('event') === 'messages.received' && ! $request->boolean('data.fromMe')) {
ProcessarMensagemRecebida::dispatch(
$request->input('sessionId'),
$request->input('data'),
);
}
return response()->json(['ok' => true]);
}
}
// dentro de ProcessarMensagemRecebida::handle()
if (! Cache::add('wa:msg:' . $this->data['id'], true, now()->addHour())) {
return; // evento repetido, já tratado
}O Cache::add só grava se a chave não existe, o que resolve reenvios do mesmo evento. O webhook de WhatsApp não é assinado, por isso o token na URL e o hash_equals fazem a validação.
Testar sem mandar mensagem de verdade
Como toda chamada passa pela Http facade, o Http::fake intercepta os envios nos testes do PHPUnit ou do Pest. Nenhuma mensagem sai para um número real, e você consegue verificar exatamente o que o Job mandou para a API, inclusive o comportamento diante de uma resposta de erro.
Http::fake([
'api.d-api.cloud/api/v1/messages/send/text' => Http::response(
['success' => false, 'error' => 'Session not connected', 'statusCode' => 400], 400
),
]);
(new EnviarWhatsApp('5511999999999', 'Teste'))->withFakeQueueInteractions()->handle();
Http::assertSent(fn ($request) =>
$request->hasHeader('Authorization', config('services.dapi.key'))
&& $request['to'] === '5511999999999'
);Vale cobrir pelo menos três cenários: envio aceito, resposta 4xx marcando o Job como falho sem nova tentativa, e resposta 5xx relançando a exceção. Para a Notification, o Notification::fake já confirma que o canal certo foi escolhido sem nem chegar ao Job. Com esses testes no CI, uma mudança de payload ou de configuração quebra o build antes de quebrar o atendimento do cliente.
Quando o sistema atende vários clientes
Em um SaaS feito em Laravel, cada cliente conecta o próprio número. Guarde o sessionId no model do tenant e passe para o Job, em vez de usar o valor fixo do config. No webhook, o campo sessionId diz de qual cliente é a mensagem. A organização de muitas conexões está em múltiplos números no WhatsApp, e o modelo comercial para esse perfil em API de WhatsApp para SaaS. Se parte do seu sistema ainda é PHP sem framework, o guia de PHP com cURL cobre esse cenário.
