Sending messages from Symfony with the Sent PHP SDK
This guide shows you how to wire Sent messaging into an existing Symfony app: install the PHP SDK, register a shared client service, send a template message from a controller, receive delivery webhooks, and verify the whole loop in sandbox mode.
Prerequisites
This guide assumes a working Symfony 7 app and familiarity with controllers, services, and attributes. You also need:
- A Sent API key from the API Keys page in your Sent Dashboard
- A public HTTPS URL for webhook delivery. For local work, open a tunnel as described in the webhook local development guide
Register the client service
Add the credentials to .env.local; the webhook secret arrives in step 4:
# .env.local
SENT_DM_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=whsec_your_signing_secretRegister one shared client so it autowires into any service or controller:
# config/services.yaml (additions)
services:
SentDm\Client:
arguments:
$apiKey: '%env(SENT_DM_API_KEY)%'Send a template message from a controller
Define a validated payload DTO; the pass-through sandbox flag lets callers exercise the endpoint without delivering anything:
// src/Dto/SendMessageDto.php
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
class SendMessageDto
{
#[Assert\NotBlank]
#[Assert\Regex(pattern: '/^\+[1-9]\d{1,14}$/', message: 'Phone number must be in E.164 format')]
public string $phoneNumber;
#[Assert\NotBlank]
#[Assert\Length(min: 1, max: 100)]
public string $templateName;
public ?array $parameters = null;
#[Assert\All([new Assert\Choice(choices: ['sms', 'whatsapp', 'rcs'])])]
public ?array $channels = null;
public bool $sandbox = false; // true = validate and simulate only
}Add the controller that maps the payload and calls messages->send:
// src/Controller/MessagesController.php
namespace App\Controller;
use App\Dto\SendMessageDto;
use SentDm\Client;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/messages')]
class MessagesController extends AbstractController
{
public function __construct(private readonly Client $client) {}
#[Route('/send', methods: ['POST'])]
public function send(#[MapRequestPayload] SendMessageDto $dto): JsonResponse
{
$response = $this->client->messages->send(
to: [$dto->phoneNumber],
template: [
'name' => $dto->templateName, // reference by name or id, never both
'parameters' => $dto->parameters ?? [],
],
channel: $dto->channels, // omit to let Sent pick per recipient
sandbox: $dto->sandbox,
);
$recipient = $response->data->recipients[0];
return $this->json([
'message_id' => $recipient->messageID,
'status' => $response->data->status,
], 202);
}
}Sent accepts sends asynchronously: the API responds with status QUEUED and one message_id per recipient-and-channel pair. Store the message_id. Delivery outcomes arrive on your webhook endpoint instead of in this response.
Receive delivery webhooks
Add a webhook controller that verifies the X-Webhook-Signature header against the raw request body before trusting any event. The scheme is HMAC-SHA256 over {X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}, keyed with the base64-decoded secret after stripping its whsec_ prefix. Refer to webhook signature verification for the full scheme. Every event arrives in the same envelope (field, event, timestamp, payload), so one handler routes all of them:
// src/Controller/WebhookController.php
namespace App\Controller;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
class WebhookController
{
public function __construct(
#[Autowire(env: 'SENT_DM_WEBHOOK_SECRET')] private readonly ?string $webhookSecret,
private readonly LoggerInterface $logger,
) {}
#[Route('/webhooks/sent', methods: ['POST'])]
public function handle(Request $request): JsonResponse
{
if (empty($this->webhookSecret)) {
// Fail closed: never accept webhooks without a configured secret
return new JsonResponse(['error' => 'Webhook not configured'], 500);
}
$signature = $request->headers->get('X-Webhook-Signature');
if (!$signature) {
return new JsonResponse(['error' => 'Missing signature'], 401);
}
$payload = $request->getContent();
$webhookId = $request->headers->get('X-Webhook-ID', '');
$timestamp = $request->headers->get('X-Webhook-Timestamp', '');
// Strip the "whsec_" prefix and base64-decode to get the raw HMAC key
$keyBase64 = str_starts_with($this->webhookSecret, 'whsec_') ? substr($this->webhookSecret, 6) : $this->webhookSecret;
$keyBytes = base64_decode($keyBase64);
// Signed content = "{webhookId}.{timestamp}.{rawBody}"; signature format = "v1,{base64(hmac)}"
$signed = "{$webhookId}.{$timestamp}.{$payload}";
$expected = 'v1,' . base64_encode(hash_hmac('sha256', $signed, $keyBytes, true));
if (!hash_equals($expected, $signature)) {
$this->logger->warning('Invalid webhook signature', ['ip' => $request->getClientIp()]);
return new JsonResponse(['error' => 'Invalid signature'], 401);
}
$event = json_decode($payload, true) ?? [];
$subType = $event['event'] ?? null; // omitted for template events
$data = $event['payload'] ?? [];
if (($event['field'] ?? null) === 'message') {
match ($subType) {
'message.delivered' => $this->logger->info('Message delivered',
['message_id' => $data['message_id'] ?? null]),
'message.failed' => $this->logger->error('Message failed',
['message_id' => $data['message_id'] ?? null, 'status' => $data['message_status'] ?? null]),
'message.received' => $this->logger->info('Inbound message',
['from' => $data['inbound_number'] ?? null, 'text' => $data['text'] ?? null]),
default => $this->logger->info('Message status updated',
['message_id' => $data['message_id'] ?? null, 'status' => $data['message_status'] ?? null]),
};
}
return new JsonResponse(['received' => true]);
}
}Keep this route outside your firewall's authenticated area (config/packages/security.yaml), because Sent authenticates with the signature. Then tell Sent where to deliver events. If you prefer a UI, use the webhooks getting started guide; otherwise register over the API:
curl -X POST https://api.sent.dm/v3/webhooks \
-H "x-api-key: $SENT_DM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"display_name": "Symfony integration",
"endpoint_url": "https://your-domain.example/webhooks/sent",
"event_types": ["message"]
}'Copy two values from the response: the webhook id (used to test delivery in the next step) and signing_secret. Put the secret in SENT_DM_WEBHOOK_SECRET. The message event type covers every message event; the webhook event types reference lists all payload fields.
Verify the integration
Start the app with your credentials loaded:
symfony server:startSend a sandbox message through your new endpoint. Full validation runs, but nothing is delivered and no credits are consumed:
curl -X POST http://localhost:8000/api/messages/send \
-H "Content-Type: application/json" \
-d '{"phoneNumber": "+14155551234", "templateName": "welcome",
"parameters": {"name": "Ada"}, "sandbox": true}'The response should contain a message_id and "status": "QUEUED". A 422 here means the request shape is wrong: sandbox requests return real validation errors.
Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook id you copied:
curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \
-H "x-api-key: $SENT_DM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event_type": "message.delivered"}'Your application log should show a Message delivered line, and the endpoint should have answered 200 {"received": true}. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix.
Adapt this to your app
- Sent retries non-2xx webhook responses with backoff, so make processing idempotent. Keying status updates on
payload.message_idachieves this naturally; see handling webhook retries. - If you send outside the request cycle, dispatch a Messenger command and let a worker call the API. The appendix below has the pattern.
- If you rate-limit send endpoints, use Symfony's
rate_limitercomponent. The appendix has the configuration. - To send free-form text instead of a template, pass
textinstead oftemplate, since each send carries exactly one of the two.
Appendix: production scaffolding
The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Symfony stack. Adapt them to your own conventions rather than adopting them wholesale.
Next steps
- Review the webhook event types reference for every payload field
- Work through the webhook production checklist before going live
- Explore the PHP SDK reference for retries, timeouts, and error types
- Read the SDK best practices guide for production deployments
Sending messages from Laravel with the Sent PHP SDK
Wire the Sent PHP SDK into a Laravel app: install, bind the client in a provider, send messages from a controller, verify webhooks, and test with sandbox mode.
Ruby SDK
Official Ruby SDK for Sent. Elegant DSL for Rails applications with full type support.