PHP SDK

Laravel Integration

Complete Laravel 11 integration with dependency injection, service layer, queues, webhooks, and comprehensive testing.

This guide follows Laravel 11 best practices with PHP 8.2+ features including readonly properties and typed parameters.

Installation & Setup

Install Package

composer require sentdm/sent-dm-php

Environment Configuration

# .env
SENT_DM_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=your_webhook_secret
SENT_DM_BASE_URL=https://api.sent.dm
SENT_DM_TIMEOUT=30
SENT_DM_MAX_RETRIES=3

Configuration File

<?php
// config/sent-dm.php
return [
    'api_key' => env('SENT_DM_API_KEY'),
    'webhook_secret' => env('SENT_DM_WEBHOOK_SECRET'),
    'base_url' => env('SENT_DM_BASE_URL', 'https://api.sent.dm'),
    'timeout' => env('SENT_DM_TIMEOUT', 30),
    'max_retries' => env('SENT_DM_MAX_RETRIES', 3),
    'webhook' => [
        'queue_connection' => env('SENT_DM_WEBHOOK_QUEUE', 'default'),
        'queue_name' => env('SENT_DM_WEBHOOK_QUEUE_NAME', 'webhooks'),
        'log_enabled' => env('SENT_DM_WEBHOOK_LOG_ENABLED', true),
        'log_retention_days' => env('SENT_DM_WEBHOOK_LOG_RETENTION_DAYS', 30),
    ],
];

Service Provider

<?php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use SentDM\Client;
use App\Services\SentDM\SentDMService;
use App\Services\SentDM\Contracts\SentDMServiceContract;

class SentDMServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Client::class, function ($app) {
            $config = $app['config']['sent-dm'];
            if (empty($config['api_key'])) {
                throw new \InvalidArgumentException('Sent DM API key not configured.');
            }
            return new Client(
                apiKey: $config['api_key'],
                baseUrl: $config['base_url'] ?? null,
                timeout: $config['timeout'] ?? 30,
                maxRetries: $config['max_retries'] ?? 3,
            );
        });
        $this->app->singleton(SentDMServiceContract::class, SentDMService::class);
        $this->app->alias(Client::class, 'sent-dm');
    }

    public function boot(): void
    {
        $this->publishes([__DIR__.'/../../config/sent-dm.php' => config_path('sent-dm.php')], 'sent-dm-config');
        $this->publishes([__DIR__.'/../../database/migrations' => database_path('migrations')], 'sent-dm-migrations');
    }
}

Register in bootstrap/providers.php:

<?php
return [
    App\Providers\AppServiceProvider::class,
    App\Providers\SentDMServiceProvider::class,
];

Service Layer

<?php
namespace App\Services\SentDM\Contracts;

interface SentDMServiceContract
{
    public function sendMessage(string $phoneNumber, string $templateId, string $templateName, array $parameters = [], ?array $channels = null, bool $testMode = false): MessageResult;
    public function sendWelcomeMessage(string $phoneNumber, ?string $name = null): MessageResult;
    public function sendOrderConfirmation(string $phoneNumber, string $orderNumber, string $total): MessageResult;
    public function verifyWebhookSignature(string $payload, string $signature): bool;
}
<?php
namespace App\Services\SentDM;

use App\Services\SentDM\Contracts\MessageResult;
use App\Services\SentDM\Contracts\SentDMServiceContract;
use Illuminate\Support\Facades\Log;
use SentDM\Client;
use SentDM\Core\Exceptions\APIException;

readonly class MessageResult
{
    public function __construct(
        public bool $success,
        public ?string $messageId = null,
        public ?string $status = null,
        public ?array $data = null,
        public ?string $error = null,
    ) {}
}

class SentDMService implements SentDMServiceContract
{
    public function __construct(private Client $client) {}

    public function sendMessage(string $phoneNumber, string $templateId, string $templateName, array $parameters = [], ?array $channels = null, bool $testMode = false): MessageResult
    {
        try {
            $response = $this->client->messages->send(
                to: [$phoneNumber],
                template: ['id' => $templateId, 'name' => $templateName, 'parameters' => $parameters],
                channels: $channels,
                testMode: $testMode,
            );
            $message = $response->data->messages[0];
            Log::info('Message sent', ['message_id' => $message->id, 'phone' => $phoneNumber]);
            return new MessageResult(success: true, messageId: $message->id, status: $message->status, data: (array) $response->data);
        } catch (APIException $e) {
            Log::error('Failed to send message', ['phone' => $phoneNumber, 'error' => $e->getMessage()]);
            return new MessageResult(success: false, error: $e->getMessage());
        }
    }

    public function sendWelcomeMessage(string $phoneNumber, ?string $name = null): MessageResult
    {
        return $this->sendMessage(phoneNumber: $phoneNumber, templateId: config('sent-dm.templates.welcome.id'), templateName: 'welcome', parameters: ['name' => $name ?? 'Valued Customer'], channels: ['whatsapp']);
    }

    public function sendOrderConfirmation(string $phoneNumber, string $orderNumber, string $total): MessageResult
    {
        return $this->sendMessage(phoneNumber: $phoneNumber, templateId: config('sent-dm.templates.order_confirmation.id'), templateName: 'order_confirmation', parameters: ['order_number' => $orderNumber, 'total' => $total], channels: ['sms', 'whatsapp']);
    }

    public function verifyWebhookSignature(string $payload, string $signature): bool
    {
        $secret = config('sent-dm.webhook_secret');
        if (empty($secret)) {
            Log::warning('Webhook secret not configured');
            return false;
        }
        return hash_equals(hash_hmac('sha256', $payload, $secret), $signature);
    }
}

Form Requests

<?php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class SendMessageRequest extends FormRequest
{
    public function authorize(): bool { return true; }

    public function rules(): array
    {
        return [
            'phone_number' => ['required', 'string', 'regex:/^\+[1-9]\d{1,14}$/'],
            'template_id' => ['required', 'string', 'uuid'],
            'template_name' => ['required', 'string', 'max:255'],
            'parameters' => ['sometimes', 'array'],
            'parameters.*' => ['string', 'max:1000'],
            'channels' => ['sometimes', 'array'],
            'channels.*' => ['string', Rule::in(['sms', 'whatsapp', 'viber', 'telegram'])],
            'test_mode' => ['sometimes', 'boolean'],
        ];
    }

    public function messages(): array
    {
        return ['phone_number.regex' => 'Phone number must be in E.164 format (e.g., +1234567890).'];
    }

    protected function prepareForValidation(): void
    {
        if ($this->has('phone_number')) {
            $this->merge(['phone_number' => preg_replace('/\s+/', '', $this->phone_number)]);
        }
    }
}
<?php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class SendWelcomeRequest extends FormRequest
{
    public function authorize(): bool { return true; }

    public function rules(): array
    {
        return [
            'phone_number' => ['required', 'string', 'regex:/^\+[1-9]\d{1,14}$/'],
            'name' => ['nullable', 'string', 'max:255'],
        ];
    }
}

Controller

<?php
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\SendMessageRequest;
use App\Http\Requests\SendWelcomeRequest;
use App\Services\SentDM\Contracts\SentDMServiceContract;
use Illuminate\Http\JsonResponse;

class MessageController extends Controller
{
    public function __construct(private SentDMServiceContract $sentService) {}

    public function send(SendMessageRequest $request): JsonResponse
    {
        $validated = $request->validated();
        $result = $this->sentService->sendMessage(
            phoneNumber: $validated['phone_number'],
            templateId: $validated['template_id'],
            templateName: $validated['template_name'],
            parameters: $validated['parameters'] ?? [],
            channels: $validated['channels'] ?? null,
            testMode: $validated['test_mode'] ?? false,
        );
        return $result->success
            ? response()->json(['success' => true, 'data' => ['message_id' => $result->messageId, 'status' => $result->status]])
            : response()->json(['success' => false, 'error' => ['message' => $result->error, 'code' => 'SEND_FAILED']], 400);
    }

    public function welcome(SendWelcomeRequest $request): JsonResponse
    {
        $validated = $request->validated();
        $result = $this->sentService->sendWelcomeMessage(phoneNumber: $validated['phone_number'], name: $validated['name'] ?? null);
        return $result->success
            ? response()->json(['success' => true, 'data' => ['message_id' => $result->messageId, 'status' => $result->status]])
            : response()->json(['success' => false, 'error' => ['message' => $result->error, 'code' => 'WELCOME_SEND_FAILED']], 400);
    }
}

Routes

<?php
// routes/api.php
use App\Http\Controllers\Api\MessageController;
use App\Http\Controllers\WebhookController;
use Illuminate\Support\Facades\Route;

Route::middleware(['auth:sanctum', 'throttle:sent-dm'])->group(function () {
    Route::post('/messages/send', [MessageController::class, 'send'])->name('api.messages.send');
    Route::post('/messages/welcome', [MessageController::class, 'welcome'])->name('api.messages.welcome');
});

Route::post('/webhooks/sent-dm', [WebhookController::class, 'handle'])->name('webhooks.sent-dm')->middleware('sent-dm.webhook');

Configure rate limiting in app/Providers/AppServiceProvider.php:

<?php
namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        RateLimiter::for('sent-dm', fn (Request $request) => [
            Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()),
        ]);
    }
}

Middleware

<?php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

class VerifySentDMWebhook
{
    public function handle(Request $request, Closure $next): Response
    {
        $signature = $request->header('X-Webhook-Signature');
        $secret = config('sent-dm.webhook_secret');
        if (empty($secret)) {
            Log::error('Webhook secret not configured');
            return response()->json(['error' => 'Webhook not configured'], 500);
        }
        if (empty($signature)) {
            return response()->json(['error' => 'Missing signature'], 401);
        }
        $expected = hash_hmac('sha256', $request->getContent(), $secret);
        if (!hash_equals($expected, $signature)) {
            Log::warning('Invalid webhook signature', ['ip' => $request->ip()]);
            return response()->json(['error' => 'Invalid signature'], 401);
        }
        return $next($request);
    }
}

Register in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias(['sent-dm.webhook' => \App\Http\Middleware\VerifySentDMWebhook::class]);
})

Webhook Handling

Migration

<?php
// database/migrations/2024_01_01_000001_create_webhook_logs_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('webhook_logs', function (Blueprint $table) {
            $table->id();
            $table->string('event_type', 100)->index();
            $table->string('event_id', 100)->unique();
            $table->uuid('message_id')->nullable()->index();
            $table->string('status', 50)->nullable();
            $table->json('payload');
            $table->string('signature', 100)->nullable();
            $table->ipAddress('source_ip')->nullable();
            $table->timestamp('processed_at')->nullable();
            $table->text('error')->nullable();
            $table->timestamps();
            $table->index(['event_type', 'created_at']);
        });
    }

    public function down(): void { Schema::dropIfExists('webhook_logs'); }
};

Model & Events

<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class WebhookLog extends Model
{
    protected $fillable = ['event_type', 'event_id', 'message_id', 'status', 'payload', 'signature', 'source_ip', 'processed_at', 'error'];
    protected $casts = ['payload' => 'array', 'processed_at' => 'datetime'];
}
<?php
namespace App\Events;

use App\Models\WebhookLog;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class MessageStatusUpdated
{
    use Dispatchable, SerializesModels;
    public function __construct(public string $messageId, public string $status, public ?array $error = null, public ?array $metadata = null) {}
}

Webhook Controller

<?php
namespace App\Http\Controllers;

use App\Events\MessageStatusUpdated;
use App\Models\WebhookLog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class WebhookController extends Controller
{
    public function handle(Request $request): JsonResponse
    {
        $payload = $request->all();
        $signature = $request->header('X-Webhook-Signature');
        $eventType = $request->header('X-Webhook-Event');
        $eventId = $request->header('X-Webhook-Id');

        $webhookLog = WebhookLog::create([
            'event_type' => $eventType ?? $payload['type'] ?? 'unknown',
            'event_id' => $eventId ?? uniqid('webhook_', true),
            'message_id' => $payload['data']['id'] ?? null,
            'status' => $payload['data']['status'] ?? null,
            'payload' => $payload,
            'signature' => $signature,
            'source_ip' => $request->ip(),
        ]);

        try {
            $this->processWebhook($eventType ?? $payload['type'], $payload);
            $webhookLog->update(['processed_at' => now()]);
            return response()->json(['received' => true]);
        } catch (\Exception $e) {
            Log::error('Webhook processing failed', ['event_id' => $webhookLog->event_id, 'error' => $e->getMessage()]);
            $webhookLog->update(['error' => $e->getMessage(), 'processed_at' => now()]);
            return response()->json(['received' => true]);
        }
    }

    private function processWebhook(?string $eventType, array $payload): void
    {
        match ($eventType) {
            'message.status.updated' => $this->handleStatusUpdate($payload),
            'message.delivered' => Log::info("Message delivered: {$payload['data']['id']}"),
            'message.failed' => $this->handleFailed($payload),
            'message.read' => Log::info("Message read: {$payload['data']['id']}"),
            default => Log::warning("Unhandled webhook: {$eventType}"),
        };
    }

    private function handleStatusUpdate(array $payload): void
    {
        $data = $payload['data'];
        MessageStatusUpdated::dispatch(messageId: $data['id'], status: $data['status'], metadata: $data['metadata'] ?? null);
        Log::info("Status updated: {$data['id']} -> {$data['status']}");
    }

    private function handleFailed(array $payload): void
    {
        $data = $payload['data'];
        MessageStatusUpdated::dispatch(messageId: $data['id'], status: 'failed', error: $data['error'] ?? null);
        Log::error("Message failed: {$data['id']}", ['error' => $data['error'] ?? null]);
    }
}

Queue Jobs

<?php
namespace App\Jobs;

use App\Services\SentDM\Contracts\SentDMServiceContract;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RateLimitedWithRedis;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;

class SendMessageJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public array $backoff = [30, 60, 120];
    private string $uniqueId;

    public function __construct(
        public readonly string $phoneNumber,
        public readonly string $templateId,
        public readonly string $templateName,
        public readonly array $parameters = [],
        public readonly ?array $channels = null,
        public readonly bool $testMode = false,
    ) {
        $this->uniqueId = 'sent-msg-' . md5($phoneNumber . $templateId . time());
    }

    public function middleware(): array
    {
        return [(new RateLimitedWithRedis('sent-dm'))->dontRelease()];
    }

    public function handle(SentDMServiceContract $sentService): void
    {
        Log::info('Processing message job', ['phone' => $this->phoneNumber, 'attempt' => $this->attempts()]);
        $result = $sentService->sendMessage($this->phoneNumber, $this->templateId, $this->templateName, $this->parameters, $this->channels, $this->testMode);
        if (!$result->success) {
            throw new \RuntimeException("Failed: {$result->error}");
        }
        Log::info('Message job completed', ['message_id' => $result->messageId]);
    }

    public function failed(Throwable $exception): void
    {
        Log::error('Message job failed', ['phone' => $this->phoneNumber, 'error' => $exception->getMessage()]);
    }

    public function uniqueId(): string { return $this->uniqueId; }
}

Dispatch jobs:

SendMessageJob::dispatch('+1234567890', 'template-id', 'welcome', ['name' => 'John'], ['whatsapp'])->onQueue('messages');
SendMessageJob::dispatch(...$args)->delay(now()->addMinutes(5));

Testing

<?php
namespace Tests;

use App\Jobs\SendMessageJob;
use App\Models\WebhookLog;
use App\Services\SentDM\Contracts\MessageResult;
use App\Services\SentDM\Contracts\SentDMServiceContract;
use App\Services\SentDM\SentDMService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Mockery as m;
use SentDM\Client;
use SentDM\Core\Exceptions\BadRequestException;
use Tests\TestCase;

class SentDMTest extends TestCase
{
    use RefreshDatabase;

    protected function tearDown(): void
    {
        m::close();
        parent::tearDown();
    }

    // Service Tests
    public function test_send_message_success(): void
    {
        $client = m::mock(Client::class);
        $service = new SentDMService($client);
        $mockResponse = (object) ['data' => (object) ['messages' => [(object) ['id' => 'msg_123', 'status' => 'pending']]]];
        $client->messages = m::mock();
        $client->messages->shouldReceive('send')->once()->andReturn($mockResponse);
        $result = $service->sendMessage('+1234567890', 'tpl-123', 'welcome', [], ['whatsapp']);
        $this->assertTrue($result->success);
        $this->assertEquals('msg_123', $result->messageId);
    }

    public function test_send_message_failure(): void
    {
        $client = m::mock(Client::class);
        $service = new SentDMService($client);
        $client->messages = m::mock();
        $client->messages->shouldReceive('send')->once()->andThrow(new BadRequestException('Invalid phone'));
        $result = $service->sendMessage('invalid', 'tpl', 'welcome');
        $this->assertFalse($result->success);
    }

    public function test_verify_webhook_signature(): void
    {
        $service = new SentDMService(m::mock(Client::class));
        $payload = '{"type":"test"}';
        $secret = 'test-secret';
        $signature = hash_hmac('sha256', $payload, $secret);
        config(['sent-dm.webhook_secret' => $secret]);
        $this->assertTrue($service->verifyWebhookSignature($payload, $signature));
        $this->assertFalse($service->verifyWebhookSignature($payload, 'invalid'));
    }

    // Controller Tests
    public function test_can_send_message(): void
    {
        $user = \App\Models\User::factory()->create();
        $sentService = m::mock(SentDMServiceContract::class);
        $sentService->shouldReceive('sendMessage')->once()->andReturn(new MessageResult(success: true, messageId: 'msg_123', status: 'queued'));
        $this->app->instance(SentDMServiceContract::class, $sentService);
        $response = $this->actingAs($user, 'sanctum')->postJson('/api/messages/send', [
            'phone_number' => '+1234567890', 'template_id' => 'tpl-123', 'template_name' => 'welcome'
        ]);
        $response->assertOk()->assertJson(['success' => true, 'data' => ['message_id' => 'msg_123']]);
    }

    public function test_validation_fails_with_invalid_phone(): void
    {
        $user = \App\Models\User::factory()->create();
        $response = $this->actingAs($user, 'sanctum')->postJson('/api/messages/send', [
            'phone_number' => 'invalid', 'template_id' => 'tpl-123', 'template_name' => 'welcome'
        ]);
        $response->assertUnprocessable()->assertJsonValidationErrors(['phone_number']);
    }

    // Webhook Tests
    public function test_accepts_valid_webhook(): void
    {
        $payload = ['type' => 'message.status.updated', 'data' => ['id' => 'msg_123', 'status' => 'delivered']];
        $signature = hash_hmac('sha256', json_encode($payload), 'test-webhook-secret');
        $response = $this->postJson('/api/webhooks/sent-dm', $payload, [
            'X-Webhook-Signature' => $signature,
            'X-Webhook-Event' => 'message.status.updated',
            'X-Webhook-Id' => 'evt_123'
        ]);
        $response->assertOk()->assertJson(['received' => true]);
        $this->assertDatabaseHas('webhook_logs', ['event_type' => 'message.status.updated', 'message_id' => 'msg_123']);
    }

    public function test_rejects_invalid_signature(): void
    {
        $response = $this->postJson('/api/webhooks/sent-dm', ['type' => 'test'], ['X-Webhook-Signature' => 'invalid']);
        $response->assertUnauthorized()->assertJson(['error' => 'Invalid signature']);
    }

    // Job Tests
    public function test_job_sends_message(): void
    {
        $sentService = m::mock(SentDMServiceContract::class);
        $sentService->shouldReceive('sendMessage')->once()->andReturn(new MessageResult(success: true, messageId: 'msg_123'));
        $this->app->instance(SentDMServiceContract::class, $sentService);
        $job = new SendMessageJob('+1234567890', 'tpl-123', 'welcome');
        $job->handle($sentService);
        $this->assertTrue(true);
    }

    public function test_job_can_be_dispatched(): void
    {
        Queue::fake();
        SendMessageJob::dispatch('+1234567890', 'tpl-123', 'welcome');
        Queue::assertPushed(SendMessageJob::class, fn ($job) => $job->phoneNumber === '+1234567890');
    }
}

Project Structure

app/
├── Console/Commands/SendTestMessage.php
├── Events/MessageStatusUpdated.php
├── Http/
│   ├── Controllers/Api/MessageController.php
│   ├── Controllers/WebhookController.php
│   ├── Middleware/VerifySentDMWebhook.php
│   ├── Requests/SendMessageRequest.php
│   └── Requests/SendWelcomeRequest.php
├── Jobs/SendMessageJob.php
├── Models/WebhookLog.php
├── Providers/SentDMServiceProvider.php
└── Services/SentDM/
    ├── Contracts/SentDMServiceContract.php
    ├── Contracts/MessageResult.php
    └── SentDMService.php
config/sent-dm.php
database/migrations/2024_01_01_000001_create_webhook_logs_table.php
routes/api.php
tests/SentDMTest.php

Environment Variables

SENT_DM_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=your_webhook_secret
SENT_DM_BASE_URL=https://api.sent.dm
SENT_DM_TIMEOUT=30
SENT_DM_MAX_RETRIES=3
SENT_DM_WEBHOOK_QUEUE=default
SENT_DM_WEBHOOK_QUEUE_NAME=webhooks

Next Steps

On this page