PHP SDK

Sending messages from Laravel with the Sent PHP SDK

This guide shows you how to wire Sent messaging into an existing Laravel app: install the PHP SDK, bind a shared client in the container, send a template message from a controller, receive delivery webhooks, and verify the whole loop in sandbox mode.

Prerequisites

This guide assumes a working Laravel 11 app and familiarity with controllers, middleware, and the service container. You also need:

Install the SDK

Add the SDK to your existing project:

composer require sentdm/sent-dm-php

Configure and bind the client

Add the credentials to .env; the webhook secret arrives in step 4:

# .env
SENT_DM_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=whsec_your_signing_secret

Expose them through a config file so config:cache works in production:

<?php
// config/sent-dm.php
return [
    'api_key' => env('SENT_DM_API_KEY'),
    'webhook_secret' => env('SENT_DM_WEBHOOK_SECRET'),
];

Bind one shared client in the container:

<?php
// app/Providers/AppServiceProvider.php (register method)
use SentDm\Client;

$this->app->singleton(Client::class, function ($app) {
    $apiKey = $app['config']['sent-dm.api_key'];
    if (empty($apiKey)) {
        throw new \InvalidArgumentException('Sent API key not configured.');
    }
    return new Client(apiKey: $apiKey);
});

Send a template message from a controller

Add a controller that calls messages->send; the pass-through sandbox flag lets callers exercise the endpoint without delivering anything:

<?php
// app/Http/Controllers/MessageController.php
namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use SentDm\Client;

class MessageController extends Controller
{
    public function __construct(private Client $client) {}

    public function send(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'phone_number' => ['required', 'regex:/^\+[1-9]\d{1,14}$/'],  // E.164 format
            'template_name' => ['required', 'string', 'max:100'],
            'parameters' => ['array'],
            'channels' => ['array'],
            'sandbox' => ['boolean'],
        ]);

        $response = $this->client->messages->send(
            to: [$validated['phone_number']],
            template: [
                'name' => $validated['template_name'],   // reference by name or id, never both
                'parameters' => $validated['parameters'] ?? [],
            ],
            channel: $validated['channels'] ?? null,     // omit to let Sent pick per recipient
            sandbox: $validated['sandbox'] ?? false,     // true = validate and simulate only
        );

        $recipient = $response->data->recipients[0];
        return response()->json([
            'message_id' => $recipient->messageID,
            'status' => $response->data->status,
        ], 202);
    }
}

Route it behind your existing API authentication:

<?php
// routes/api.php
Route::middleware('auth:sanctum')->post('/messages/send', [MessageController::class, 'send']);

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 middleware that verifies the X-Webhook-Signature header against the raw request body before any handler runs. 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:

<?php
// app/Http/Middleware/VerifySentWebhook.php
namespace App\Http\Middleware;

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

class VerifySentWebhook
{
    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);
        }

        $payload   = $request->getContent();
        $webhookId = $request->header('X-Webhook-ID', '');
        $timestamp = $request->header('X-Webhook-Timestamp', '');

        // Strip the "whsec_" prefix and base64-decode to get the raw HMAC key
        $keyBase64 = str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret;
        $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)) {
            Log::warning('Invalid webhook signature', ['ip' => $request->ip()]);
            return response()->json(['error' => 'Invalid signature'], 401);
        }
        return $next($request);
    }
}

Register the alias in bootstrap/app.php:

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

Add the controller. Every event arrives in the same envelope (field, event, timestamp, payload), so one handler routes all of them; return 200 quickly and do slow work in a queued job:

<?php
// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class WebhookController extends Controller
{
    public function handle(Request $request): JsonResponse
    {
        // Signature already verified by the sent.webhook middleware
        $event   = json_decode($request->getContent(), true) ?? [];
        $subType = $event['event'] ?? null;      // omitted for template events
        $payload = $event['payload'] ?? [];

        if (($event['field'] ?? null) === 'message') {
            match ($subType) {
                'message.delivered' => Log::info("Message {$payload['message_id']} delivered"),
                'message.failed' => Log::error("Message {$payload['message_id']} failed",
                    ['status' => $payload['message_status'] ?? null]),
                'message.received' => Log::info("Inbound message from {$payload['inbound_number']}",
                    ['text' => $payload['text'] ?? null]),
                default => Log::info("Message {$payload['message_id']} status: "
                    . ($payload['message_status'] ?? $subType)),
            };
        }

        return response()->json(['received' => true]);
    }
}

Route it with the verification middleware and without session authentication, because Sent authenticates with the signature:

<?php
// routes/api.php
Route::post('/webhooks/sent', [WebhookController::class, 'handle'])->middleware('sent.webhook');

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": "Laravel integration",
    "endpoint_url": "https://your-domain.example/api/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:

php artisan serve

Send a sandbox message through your new endpoint (include your API token if the route requires it). 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" \
  -H "Accept: application/json" \
  -d '{"phone_number": "+14155551234", "template_name": "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 Laravel log (storage/logs/laravel.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_id achieves this naturally; see handling webhook retries.
  • If webhook processing does slow work (database writes, notifications), dispatch a queued job from the controller and return 200 immediately. The appendix below has the job.
  • If you audit webhook traffic, persist each envelope in a webhook_logs table before processing. The appendix has the migration.
  • To send free-form text instead of a template, pass text instead of template, 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 Laravel stack. Adapt them to your own conventions rather than adopting them wholesale.

Next steps

On this page