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-phpEnvironment 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=3Configuration 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.phpEnvironment 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=webhooksNext Steps
- Learn about best practices for production deployments
- Set up webhooks for delivery status updates
- Explore the PHP SDK reference for advanced features