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.php

Installation

composer require sentdm/sent-dm-php symfony/validator symfony/serializer
composer require symfony/messenger symfony/redis-messenger  # For async

Bundle 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_secret

DTOs

// 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 coverage

Environment 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

On this page