PHP SDK
Symfony Integration
Complete Symfony 7 integration with dependency injection, configuration management, and modular architecture.
This example uses sentdm/sent-dm-php with Symfony patterns including bundles, services, DTOs, and controllers.
Project Structure
project/
├── config/
│ ├── packages/sent_dm.yaml # Bundle configuration
│ ├── packages/messenger.yaml # Async processing
│ └── bundles.php # Bundle registration
├── src/SentDmBundle/
│ ├── SentDmBundle.php # Bundle entry
│ ├── DependencyInjection/
│ │ ├── SentDmExtension.php # DI extension
│ │ └── Configuration.php # Config tree
│ ├── Controller/
│ │ └── MessagesController.php
│ ├── Dto/
│ │ ├── SendMessageDto.php
│ │ └── MessageResponseDto.php
│ ├── Service/
│ │ └── SentDmService.php
│ └── EventSubscriber/
│ └── WebhookEventSubscriber.php
└── tests/
├── Unit/SentDmServiceTest.php
└── Functional/MessagesControllerTest.phpInstallation
composer require sentdm/sent-dm-php symfony/validator symfony/serializer
composer require symfony/messenger symfony/redis-messenger # For asyncBundle Setup
// src/SentDmBundle/SentDmBundle.php
namespace App\SentDmBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
use App\SentDmBundle\DependencyInjection\SentDmExtension;
class SentDmBundle extends Bundle
{
public function getExtension(): ?\Symfony\Component\DependencyInjection\Extension\ExtensionInterface
{
return new SentDmExtension();
}
}// src/SentDmBundle/DependencyInjection/SentDmExtension.php
namespace App\SentDmBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
use Symfony\Component\DependencyInjection\Reference;
use App\SentDmBundle\Service\SentDmService;
use App\SentDmBundle\EventSubscriber\WebhookEventSubscriber;
use SentDM\Client;
class SentDmExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
$loader->load('services.yaml');
$config = $this->processConfiguration(new Configuration(), $configs);
$container->register('sent_dm.client', Client::class)
->setArgument('$apiKey', $config['api_key'])
->setArgument('$maxRetries', $config['max_retries'] ?? 2);
$container->getDefinition(SentDmService::class)
->setArgument('$client', new Reference('sent_dm.client'));
if ($config['webhook']['enabled'] ?? false) {
$container->getDefinition(WebhookEventSubscriber::class)
->setArgument('$webhookSecret', $config['webhook']['secret'] ?? null)
->addTag('kernel.event_subscriber');
}
}
public function getAlias(): string { return 'sent_dm'; }
}// src/SentDmBundle/DependencyInjection/Configuration.php
namespace App\SentDmBundle\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('sent_dm');
$rootNode = $treeBuilder->getRootNode();
$rootNode->children()
->scalarNode('api_key')->isRequired()->end()
->integerNode('max_retries')->defaultValue(2)->end()
->arrayNode('webhook')->addDefaultsIfNotSet()->children()
->booleanNode('enabled')->defaultValue(false)->end()
->scalarNode('secret')->defaultNull()->end()
->scalarNode('path')->defaultValue('/webhooks/sent')->end()
->end()->end()
->end();
return $treeBuilder;
}
}Configuration
# config/packages/sent_dm.yaml
sent_dm:
api_key: '%env(SENT_DM_API_KEY)%'
max_retries: 2
webhook:
enabled: true
secret: '%env(SENT_DM_WEBHOOK_SECRET)%'
path: '/webhooks/sent'# src/SentDmBundle/Resources/config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\SentDmBundle\:
resource: '../../*'
exclude: ['../../DependencyInjection/', '../../Resources/']
App\SentDmBundle\Service\SentDmService: ~
App\SentDmBundle\EventSubscriber\WebhookEventSubscriber: ~# .env
SENT_DM_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=your_webhook_secretDTOs
// src/SentDmBundle/Dto/SendMessageDto.php
namespace App\SentDmBundle\Dto;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Annotation as Serializer;
class SendMessageDto
{
#[Assert\NotBlank]
#[Assert\Regex(pattern: '/^\+[1-9]\d{1,14}$/', message: 'Phone number must be in E.164 format')]
#[Serializer\Groups(['write'])]
public string $phoneNumber;
#[Assert\NotBlank]
#[Assert\Uuid]
#[Serializer\Groups(['write'])]
public string $templateId;
#[Assert\NotBlank]
#[Assert\Length(min: 1, max: 100)]
#[Serializer\Groups(['write'])]
public string $templateName;
#[Assert\Optional]
#[Serializer\Groups(['write'])]
public ?array $parameters = null;
#[Assert\Optional]
#[Assert\All([new Assert\Choice(choices: ['sms', 'whatsapp', 'telegram', 'viber'])])]
#[Serializer\Groups(['write'])]
public ?array $channels = null;
#[Assert\Optional]
#[Serializer\Groups(['write'])]
public bool $testMode = false;
public function toArray(): array
{
return [
'phoneNumber' => $this->phoneNumber,
'templateId' => $this->templateId,
'templateName' => $this->templateName,
'parameters' => $this->parameters,
'channels' => $this->channels,
'testMode' => $this->testMode,
];
}
}// src/SentDmBundle/Dto/MessageResponseDto.php
namespace App\SentDmBundle\Dto;
use Symfony\Component\Serializer\Annotation as Serializer;
class MessageResponseDto
{
#[Serializer\Groups(['read'])]
public string $messageId;
#[Serializer\Groups(['read'])]
public string $status;
#[Serializer\Groups(['read'])]
public ?string $channel = null;
public function __construct(string $messageId, string $status, ?string $channel = null)
{
$this->messageId = $messageId;
$this->status = $status;
$this->channel = $channel;
}
}Service Layer
// src/SentDmBundle/Service/SentDmService.php
namespace App\SentDmBundle\Service;
use Psr\Log\LoggerInterface;
use SentDM\Client;
use SentDM\Core\Exceptions\APIException;
use App\SentDmBundle\Dto\MessageResponseDto;
use App\SentDmBundle\Dto\SendMessageDto;
class SentDmService
{
public function __construct(
private readonly Client $client,
private readonly LoggerInterface $logger,
) {}
public function sendMessage(SendMessageDto $dto): MessageResponseDto
{
try {
$response = $this->client->messages->send(
to: [$dto->phoneNumber],
template: [
'id' => $dto->templateId,
'name' => $dto->templateName,
'parameters' => $dto->parameters ?? [],
],
channels: $dto->channels,
testMode: $dto->testMode,
);
$message = $response->data->messages[0];
$this->logger->info('Message sent', ['message_id' => $message->id]);
return new MessageResponseDto($message->id, $message->status, $message->channel ?? null);
} catch (APIException $e) {
$this->logger->error('Failed to send message', ['error' => $e->getMessage()]);
throw $e;
}
}
public function getMessageStatus(string $messageId): array
{
try {
$response = $this->client->messages->get($messageId);
return [
'id' => $response->data->id,
'status' => $response->data->status,
'channel' => $response->data->channel ?? null,
'delivered_at' => $response->data->deliveredAt ?? null,
];
} catch (APIException $e) {
$this->logger->error('Failed to get status', ['message_id' => $messageId]);
throw $e;
}
}
}Controller
// src/SentDmBundle/Controller/MessagesController.php
namespace App\SentDmBundle\Controller;
use App\SentDmBundle\Dto\SendMessageDto;
use App\SentDmBundle\Service\SentDmService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
#[Route('/api/messages')]
class MessagesController extends AbstractController
{
public function __construct(private readonly SentDmService $sentDmService) {}
#[Route('/send', methods: ['POST'])]
public function send(
#[MapRequestPayload] SendMessageDto $dto,
RateLimiterFactory $sentDmMessageLimiter,
): JsonResponse {
$limiter = $sentDmMessageLimiter->create($this->getUser()?->getUserIdentifier() ?? 'anonymous');
if (!$limiter->consume(1)->isAccepted()) {
throw new TooManyRequestsHttpException();
}
$response = $this->sentDmService->sendMessage($dto);
return $this->json($response, 200, [], ['groups' => ['read']]);
}
#[Route('/{messageId}/status', methods: ['GET'])]
public function status(string $messageId): JsonResponse
{
return $this->json($this->sentDmService->getMessageStatus($messageId));
}
}# config/packages/rate_limiter.yaml
framework:
rate_limiter:
sent_dm_message:
policy: 'sliding_window'
limit: 100
interval: '1 minute'Webhook Handling
// src/SentDmBundle/EventSubscriber/WebhookEventSubscriber.php
namespace App\SentDmBundle\EventSubscriber;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class WebhookEventSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly ?string $webhookSecret,
private readonly LoggerInterface $logger,
) {}
public static function getSubscribedEvents(): array
{
return [KernelEvents::REQUEST => ['onKernelRequest', 10]];
}
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
if ($request->getPathInfo() !== '/webhooks/sent' || !$request->isMethod('POST')) {
return;
}
$signature = $request->headers->get('X-Webhook-Signature');
if (!$signature) {
$event->setResponse(new JsonResponse(['error' => 'Missing signature'], Response::HTTP_UNAUTHORIZED));
return;
}
$expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), $this->webhookSecret ?? '');
if (!hash_equals($expected, $signature)) {
$this->logger->warning('Invalid webhook signature', ['ip' => $request->getClientIp()]);
$event->setResponse(new JsonResponse(['error' => 'Invalid signature'], Response::HTTP_UNAUTHORIZED));
return;
}
try {
$eventData = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$this->handleEvent($eventData);
$event->setResponse(new JsonResponse(['received' => true]));
} catch (\JsonException $e) {
$event->setResponse(new JsonResponse(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST));
}
}
private function handleEvent(array $eventData): void
{
$type = $eventData['type'] ?? 'unknown';
$data = $eventData['data'] ?? [];
match ($type) {
'message.status.updated' => $this->logger->info('Status updated', ['id' => $data['id']]),
'message.delivered' => $this->logger->info('Message delivered', ['id' => $data['id']]),
'message.failed' => $this->logger->error('Message failed', ['id' => $data['id'], 'error' => $data['error']['message'] ?? '']),
default => $this->logger->warning('Unhandled event', ['type' => $type]),
};
}
}Testing
Service Tests
// tests/Unit/SentDmBundle/Service/SentDmServiceTest.php
namespace App\Tests\Unit\SentDmBundle\Service;
use App\SentDmBundle\Dto\MessageResponseDto;
use App\SentDmBundle\Dto\SendMessageDto;
use App\SentDmBundle\Service\SentDmService;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use SentDM\Client;
use SentDM\Core\Exceptions\BadRequestException;
class SentDmServiceTest extends TestCase
{
private $client;
private $service;
protected function setUp(): void
{
$this->client = $this->createMock(Client::class);
$this->service = new SentDmService($this->client, $this->createMock(LoggerInterface::class));
}
public function testSendMessageSuccess(): void
{
$dto = new SendMessageDto();
$dto->phoneNumber = '+1234567890';
$dto->templateId = '7ba7b820-9dad-11d1-80b4-00c04fd430c8';
$dto->templateName = 'welcome';
$mockResponse = (object) ['data' => (object) ['messages' => [(object) ['id' => 'msg_123', 'status' => 'pending', 'channel' => 'whatsapp']]]];
$this->client->messages = $this->createMock(\stdClass::class);
$this->client->messages->method('send')->willReturn($mockResponse);
$result = $this->service->sendMessage($dto);
$this->assertInstanceOf(MessageResponseDto::class, $result);
$this->assertEquals('msg_123', $result->messageId);
}
public function testSendMessageFailure(): void
{
$dto = new SendMessageDto();
$dto->phoneNumber = '+1234567890';
$dto->templateId = '7ba7b820-9dad-11d1-80b4-00c04fd430c8';
$dto->templateName = 'welcome';
$this->client->messages = $this->createMock(\stdClass::class);
$this->client->messages->method('send')->willThrowException(new BadRequestException('Invalid phone'));
$this->expectException(\SentDM\Core\Exceptions\APIException::class);
$this->service->sendMessage($dto);
}
}// tests/Functional/SentDmBundle/Controller/MessagesControllerTest.php
namespace App\Tests\Functional\SentDmBundle\Controller;
use App\SentDmBundle\Service\SentDmService;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
class MessagesControllerTest extends WebTestCase
{
public function testSendMessageSuccess(): void
{
$client = static::createClient();
// Mock the service to avoid actual API calls
$sentDmService = $this->createMock(SentDmService::class);
$sentDmService->expects($this->once())
->method('sendMessage')
->willReturn(new \App\SentDmBundle\Dto\MessageResponseDto('msg_123', 'pending', 'whatsapp'));
$client->getContainer()->set(SentDmService::class, $sentDmService);
$client->request('POST', '/api/messages/send', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
'phoneNumber' => '+1234567890',
'templateId' => '7ba7b820-9dad-11d1-80b4-00c04fd430c8',
'templateName' => 'welcome',
'channels' => ['whatsapp'],
]));
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$this->assertJsonContains(['messageId' => 'msg_123', 'status' => 'pending']);
}
public function testSendMessageValidation(): void
{
$client = static::createClient();
$client->request('POST', '/api/messages/send', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
'phoneNumber' => 'invalid',
'templateId' => 'not-a-uuid',
'templateName' => '',
]));
$this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
}
public function testGetMessageStatus(): void
{
$client = static::createClient();
$sentDmService = $this->createMock(SentDmService::class);
$sentDmService->expects($this->once())
->method('getMessageStatus')
->with('msg_123')
->willReturn(['id' => 'msg_123', 'status' => 'delivered', 'channel' => 'whatsapp']);
$client->getContainer()->set(SentDmService::class, $sentDmService);
$client->request('GET', '/api/messages/msg_123/status');
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$this->assertJsonContains(['status' => 'delivered']);
}
}Webhook Tests
// tests/Integration/SentDmBundle/WebhookHandlingTest.php
namespace App\Tests\Integration\SentDmBundle;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
class WebhookHandlingTest extends WebTestCase
{
public function testWebhookWithValidSignature(): void
{
$client = static::createClient();
$payload = ['type' => 'message.status.updated', 'data' => ['id' => 'msg_123', 'status' => 'delivered']];
$signature = 'sha256=' . hash_hmac('sha256', json_encode($payload), 'test-webhook-secret');
$client->request('POST', '/webhooks/sent', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_X_WEBHOOK_SIGNATURE' => $signature,
], json_encode($payload));
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
}
public function testWebhookWithInvalidSignature(): void
{
$client = static::createClient();
$client->request('POST', '/webhooks/sent', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['type' => 'test']));
$this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
}
}Running Tests
# Run all tests
php bin/phpunit
# Run only unit tests
php bin/phpunit --testsuite Unit
# Run only functional tests
php bin/phpunit --testsuite Functional
# Run with coverage report
php bin/phpunit --coverage-html coverageEnvironment Variables
// config/bundles.php
return [
// ... other bundles
App\SentDmBundle\SentDmBundle::class => ['all' => true],
];<!-- phpunit.xml.dist -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="tests/bootstrap.php" colors="true">
<php>
<server name="APP_ENV" value="test" force="true" />
<env name="SENT_DM_API_KEY" value="test-api-key" />
<env name="SENT_DM_WEBHOOK_SECRET" value="test-webhook-secret" />
</php>
<testsuites>
<testsuite name="Unit"><directory>tests/Unit</directory></testsuite>
<testsuite name="Functional"><directory>tests/Functional</directory></testsuite>
</testsuites>
</phpunit>Next Steps
- Learn about best practices for production deployments
- Set up webhooks for delivery status updates
- Explore the PHP SDK reference for advanced features