sudiptpa/laravel-sent-dm

An expressive Laravel adapter for the Sent.dm unified messaging API — SMS, WhatsApp and RCS with a fluent, elegant interface.

Maintainers

Package info

github.com/sudiptpa/laravel-sent-dm

pkg:composer/sudiptpa/laravel-sent-dm

Fund package maintenance!

sudiptpa

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-06-01 06:22 UTC

This package is auto-updated.

Last update: 2026-06-01 11:53:41 UTC


README

laravel-sent-dm

Tests Latest Stable Version License

A Laravel package for Sent.dm — the unified messaging API for SMS, WhatsApp, and RCS.

This package wraps the official sentdm/sent-dm-php SDK with a full Laravel integration layer: queued sends, notification channels, webhook handling, message logging, opt-out management, multi-tenancy, and a complete testing suite. All HTTP transport is handled by the official SDK — this package adds the Laravel idioms on top.

What this package handles

These things are wired up for you and work out of the box:

  • Queue-backed sends — every message goes through a Laravel job; the request cycle never blocks
  • Auto-channel routing — Sent.dm picks WhatsApp or SMS based on the recipient's reachability
  • Webhook signature verification — HMAC-SHA256 checked at middleware level before your code runs
  • Idempotent deduplication — webhook events are deduplicated so retried deliveries don't fire your listeners twice
  • Rate limit handling — 429 responses re-queue the job with the API's Retry-After delay, not a fixed wait
  • Caching — contacts, templates, profiles, and number lookups are cached per-key with tag-based invalidation
  • Multi-tenancy — same driver pattern as Mail and Cache; switch accounts per request with Sent::connection()
  • Message log — opt-in DB table that records every send and auto-syncs delivery status from webhooks
  • Opt-out compliance — STOP/UNSTOP keywords handled automatically; guard blocks sends to opted-out numbers
  • TestingSent::fake() with full assertions so you never make real API calls in tests

What stays in your application

These things belong in your app, not in the package:

  • Deciding when to send a message — that's business logic
  • Template content — created and managed in the Sent.dm dashboard
  • Campaign scheduling — use Laravel's schedule() to dispatch bulk sends on a cron
  • Analytics UI — build your own dashboard using $user->sentMessages() data
  • Contact import — sync from your DB using Sent::contacts()->create() in a job or command
  • Custom retry strategies — listen to MessageFailed and re-dispatch with your own logic
  • Per-user notification preferences — check $user->optedOutFromSent() before sending

Requirements

  • PHP 8.2+
  • Laravel 11, 12, or 13

Installation

composer require sudiptpa/laravel-sent-dm

Publish the config file:

php artisan sent:install

Add your API key to .env:

SENT_API_KEY=your-api-key

Verify the connection:

php artisan sent:health

Configuration

The published config is at config/sent.php:

'default' => env('SENT_CONNECTION', 'default'),

'connections' => [
    'default' => [
        'api_key' => env('SENT_API_KEY'),
    ],
],

'default_channel' => env('SENT_DEFAULT_CHANNEL'), // null = auto-route

'queue' => [
    'connection' => env('SENT_QUEUE_CONNECTION'),
    'name'       => env('SENT_QUEUE_NAME', 'default'),
],

'webhook' => [
    'enabled' => env('SENT_WEBHOOK_ENABLED', false),
    'secret'  => env('SENT_WEBHOOK_SECRET'),
    'path'    => env('SENT_WEBHOOK_PATH', 'sent/webhook'),
],

'cache' => [
    'enabled' => env('SENT_CACHE_ENABLED', true),
    'ttl'     => env('SENT_CACHE_TTL', 3600),
],

'sandbox' => env('SENT_SANDBOX', false),

'logging' => [
    'enabled' => env('SENT_LOGGING_ENABLED', false),
],

'opt_out' => [
    'enabled' => env('SENT_OPT_OUT_ENABLED', false),
    'guard'   => env('SENT_OPT_OUT_GUARD', false),
],

Sending messages

Immediate send

use Sujip\SentDm\Facades\Sent;

Sent::to('+61412345678')
    ->template('otp-verification')
    ->send();

Templates are required. Sent.dm has no raw text endpoint — every outbound message must reference a pre-approved template. Templates are created and managed in the Sent.dm dashboard.

Sent.dm auto-routes to WhatsApp if the recipient has it, otherwise falls back to SMS. To force a specific channel:

Sent::to('+61412345678')
    ->template('otp-verification')
    ->channel('sms')      // or 'whatsapp', 'rcs'
    ->send();

Template variables

Sent::to('+61412345678')
    ->template('otp-verification')
    ->with(['code' => '123456', 'expiry' => '10 minutes'])
    ->send();

Idempotency

Prevent duplicate sends if your app retries the same operation:

Sent::to('+61412345678')
    ->template('order-confirmation')
    ->idempotencyKey("order-{$order->id}")
    ->send();

Profile override

When your Sent.dm account has multiple profiles, target one per message:

Sent::to('+61412345678')
    ->template('promo')
    ->usingProfile('profile_abc123')
    ->send();

Sandbox mode (per message)

Simulate a send without real delivery — useful in staging:

Sent::to('+61412345678')
    ->template('otp-verification')
    ->sandbox()
    ->send();

Queued sends

Use sendLater() instead of send(). The request returns immediately; Laravel processes it in the background.

Sent::to('+61412345678')
    ->template('welcome')
    ->sendLater();

Configure which queue to use:

SENT_QUEUE_CONNECTION=redis
SENT_QUEUE_NAME=messages

The job retries up to 3 times with exponential backoff. If the API returns a 429, the job re-queues itself after the Retry-After delay the API provides.

App-level pattern — send on model event

// app/Observers/UserObserver.php
class UserObserver
{
    public function created(User $user): void
    {
        Sent::to($user->phone)
            ->template('welcome')
            ->for($user)
            ->sendLater();
    }
}

App-level pattern — listen to the result

// app/Listeners/HandleMessageSent.php
use Sujip\SentDm\Events\MessageSent;

class HandleMessageSent
{
    public function handle(MessageSent $event): void
    {
        if ($event->message !== null) {
            // job context — $event->message is the SentMessage
            // $event->connectionName is the Sent.dm connection used
        }
    }
}

Bulk messaging

Send the same message to a large list. Each recipient is dispatched as an individual queued job, so failures and rate limits are handled per-recipient.

$numbers = ['+61412345678', '+61498765432'];

Sent::bulk($numbers)
    ->template('flash-sale')
    ->with(['discount' => '20%'])
    ->dispatch();

Force a channel or profile for the whole batch:

Sent::bulk($numbers)
    ->template('flash-sale')
    ->channel('sms')
    ->usingProfile('profile_abc123')
    ->dispatch();

App-level pattern — scheduled campaign

// app/Console/Kernel.php (or routes/console.php in Laravel 11+)
Schedule::call(function () {
    $numbers = User::subscribed()->pluck('phone')->all();

    Sent::bulk($numbers)
        ->template('weekly-digest')
        ->dispatch();
})->weekly();

Notification channel

Use the Sent channel in any Laravel notification. Implement ProvidesSentMessage and add toSent():

use Illuminate\Notifications\Notification;
use Sujip\SentDm\Channels\SentChannel;
use Sujip\SentDm\Contracts\ProvidesSentMessage;
use Sujip\SentDm\Messages\SentMessage;

class OrderShippedNotification extends Notification implements ProvidesSentMessage
{
    public function __construct(private Order $order) {}

    public function via(mixed $notifiable): array
    {
        return [SentChannel::class];
    }

    public function toSent(mixed $notifiable): SentMessage
    {
        return SentMessage::create()
            ->template('order-shipped')
            ->with(['tracking' => $this->order->tracking_number]);
    }
}

Add HasSentContact to any model that has a phone attribute:

use Sujip\SentDm\Concerns\HasSentContact;

class User extends Model
{
    use Notifiable, HasSentContact;
}

Send the notification:

$user->notify(new OrderShippedNotification($order));

App-level pattern — skip opted-out users

public function via(mixed $notifiable): array
{
    if ($notifiable->optedOutFromSent()) {
        return [];
    }

    return [SentChannel::class];
}

Customising the phone column

If your phone column isn't called phone, override sentPhoneNumber():

class User extends Model
{
    use HasSentContact;

    protected function sentPhoneNumber(): string
    {
        return (string) ($this->mobile_number ?? '');
    }
}

Sandbox mode (global)

Enable globally to simulate all sends across all environments without real delivery:

SENT_SANDBOX=true

Sent.dm processes the request server-side and returns a real-shaped response, so events still fire and queued jobs run normally — your code path is identical to production.

Webhooks

Sent.dm POSTs events to your app when messages are delivered, read, or fail. The webhook route is opt-in.

Enable the webhook route

SENT_WEBHOOK_ENABLED=true
SENT_WEBHOOK_SECRET=whsec_...
SENT_WEBHOOK_PATH=sent/webhook

Register the endpoint with Sent.dm

php artisan sent:setup-webhook https://yourapp.com/sent/webhook

This creates the endpoint on Sent.dm and prints the signing secret to add to .env.

Subscribe to specific events only:

php artisan sent:setup-webhook https://yourapp.com/sent/webhook \
    --events=message.delivered \
    --events=message.failed

Listen to webhook events

Register listeners in AppServiceProvider or EventServiceProvider:

use Sujip\SentDm\Events\MessageDelivered;
use Sujip\SentDm\Events\MessageFailed;
use Sujip\SentDm\Events\MessageReceived;
use Sujip\SentDm\Events\MessageRead;
use Sujip\SentDm\Events\MessageSent;

// app/Providers/AppServiceProvider.php
Event::listen(MessageDelivered::class, function (MessageDelivered $event) {
    $messageId = $event->payload->messageId();
    $channel   = $event->payload->channel();
    $recipient = $event->payload->recipient();
});

Event::listen(MessageFailed::class, function (MessageFailed $event) {
    // log or alert
});

Event::listen(MessageReceived::class, function (MessageReceived $event) {
    // inbound message
    $from = $event->payload->sender();
    $text = $event->payload->text();
});

All webhook events

Event Triggered when
MessageQueued Sent.dm accepted the message
MessageRouted Channel selected
MessageSent Dispatched to the carrier
MessageDelivered Confirmed delivered to the handset
MessageRead Recipient opened it (WhatsApp)
MessageFailed Delivery failed permanently
MessageReceived Inbound message from a recipient

Every event carries a WebhookPayload with these accessors:

$event->payload->messageId();   // Sent.dm message ID
$event->payload->status();      // message status string
$event->payload->channel();     // sms, whatsapp, rcs
$event->payload->recipient();   // E.164 recipient number
$event->payload->sender();      // E.164 sender number
$event->payload->templateId();  // template used, if any
$event->payload->text();        // inbound text (message.received only)
$event->payload->subType;       // raw sub_type string
$event->payload->timestamp;     // ISO 8601 timestamp

How signature verification works

The VerifySignature middleware runs before your controller. It reads x-webhook-signature, x-webhook-id, and x-webhook-timestamp, recomputes HMAC-SHA256 over {webhook_id}.{timestamp}.{raw_body}, and rejects requests that don't match or are older than 5 minutes. Duplicate events are deduplicated by message ID + event type, so retried deliveries are safe.

Message log

The message log keeps a local record of every outbound message and syncs delivery status automatically from webhooks. Everything is opt-in — nothing writes to your database unless you enable it.

Setup

Publish the migrations and enable logging:

php artisan vendor:publish --tag=laravel-sent-migrations
php artisan migrate
SENT_LOGGING_ENABLED=true

Associate messages with a model

Use ->for($model) on any message to bind the log entry to an Eloquent model:

Sent::to($user->phone)
    ->template('order-shipped')
    ->with(['tracking' => $order->tracking])
    ->for($user)
    ->sendLater();

HasSentMessages trait

Add to any model to query message history:

use Sujip\SentDm\Concerns\HasSentMessages;

class User extends Model
{
    use HasSentMessages;
}
// all messages sent to this user
$user->sentMessages()->latest()->get();

// filter by delivery status
$user->sentMessagesWithStatus(SentLogStatus::Delivered)->count();
$user->sentMessagesWithStatus(SentLogStatus::Failed)->get();

// most recent
$user->lastSentMessage();

Querying the log — SentLog scopes

SentLog ships with composable query scopes for app-level analytics. Combine them freely:

use Sujip\SentDm\Models\SentLog;
use Sujip\SentDm\Enums\SentLogStatus;

// count by status across all logs
SentLog::groupByStatus()->get();
// → collection of rows with ->status and ->total

// per-connection breakdown (multi-tenant)
SentLog::forConnection('acme')->groupByStatus()->get();

// last 7 days, WhatsApp only
SentLog::whereSentBetween(now()->subDays(7), now())
    ->forChannel('whatsapp')
    ->groupByStatus()
    ->get();

// all delivered messages for a specific template
SentLog::forTemplate('order-shipped')
    ->forStatus(SentLogStatus::Delivered)
    ->count();

// history for a single recipient
SentLog::forRecipient('+61412345678')->latest()->get();

// compose all filters together
SentLog::forConnection('acme')
    ->forChannel('sms')
    ->forTemplate('otp')
    ->whereSentBetween(now()->startOfMonth(), now()->endOfMonth())
    ->groupByStatus()
    ->get();
Scope Description
forConnection(string) Filter by Sent.dm connection name
forChannel(string) Filter by channel (sms, whatsapp, rcs)
forTemplate(string) Filter by template name
forStatus(SentLogStatus|string) Filter by delivery status
forRecipient(string) Filter by recipient phone number
whereSentBetween($from, $to) Filter by created_at date range
groupByStatus() Aggregate — adds SELECT status, COUNT(*) as total GROUP BY status

The sent:stats command uses these same scopes internally. For scheduled reports, per-tenant dashboards, or custom analytics, query SentLog directly.

Status progression

The log is created with status queued when the job fires, then updated automatically as webhook events arrive:

queued → sent → delivered
                   ↓
                  read
              (WhatsApp only)

queued → sent → failed

App-level pattern — show message history

// In a controller or Livewire component:
$messages = $user->sentMessages()
    ->latest()
    ->paginate(20);

App-level pattern — retry failed messages

use Sujip\SentDm\Events\MessageFailed;

Event::listen(MessageFailed::class, function (MessageFailed $event) {
    if ($event->message === null) {
        return; // webhook context — no SentMessage to re-dispatch
    }

    // re-queue once with a different template
    Sent::to($event->message->getRecipient())
        ->template('delivery-fallback')
        ->sendLater();
});

SentLogStatus enum

use Sujip\SentDm\Enums\SentLogStatus;

SentLogStatus::Queued
SentLogStatus::Sent
SentLogStatus::Delivered
SentLogStatus::Failed
SentLogStatus::Read

Inbound messages (message.received webhook events) do not create a sent_logs record — the log only tracks outbound messages sent through this package.

Opt-out management

The opt-out layer tracks per-number consent, handles STOP keywords automatically, and can block outbound messages to opted-out numbers. All opt-in, nothing enabled by default.

Setup

Publish the migrations (same command as above if already done) and enable:

php artisan vendor:publish --tag=laravel-sent-migrations
php artisan migrate
SENT_OPT_OUT_ENABLED=true   # record STOP/UNSTOP from inbound messages
SENT_OPT_OUT_GUARD=true     # block sends to opted-out numbers

Inbound keyword handling

When SENT_OPT_OUT_ENABLED=true, these inbound keywords are handled automatically:

Keyword Effect
STOP UNSUBSCRIBE CANCEL END QUIT Contact is marked opted-out
START YES UNSTOP Contact is marked opted-in

No code needed — the ProcessInboundOptOut listener fires on every MessageReceived event and updates sent_opt_outs.

HasSentContact opt-out methods

HasSentContact includes opt-out management. Any model using the trait gets:

// check before sending
if ($user->optedOutFromSent()) {
    return;
}

// record a manual opt-out (e.g. from a settings page)
$user->optOutFromSent();
$user->optOutFromSent('user-requested'); // with a reason

// re-enable messaging
$user->optInToSent();

Send guard

When SENT_OPT_OUT_GUARD=true, send() and sendLater() throw ContactOptedOutException if the recipient has opted out. Catch it where it matters:

use Sujip\SentDm\Exceptions\ContactOptedOutException;

try {
    Sent::to($user->phone)->template('promo')->send();
} catch (ContactOptedOutException $e) {
    Log::info("Skipped send to opted-out number: {$e->phoneNumber}");
}

App-level pattern — settings page

// routes/web.php
Route::post('/settings/messaging/opt-out', function (Request $request) {
    $request->user()->optOutFromSent();

    return back()->with('status', 'You have opted out of SMS messages.');
});

Route::post('/settings/messaging/opt-in', function (Request $request) {
    $request->user()->optInToSent();

    return back()->with('status', 'SMS messaging re-enabled.');
});

App-level pattern — check before notification

public function via(mixed $notifiable): array
{
    if (method_exists($notifiable, 'optedOutFromSent') && $notifiable->optedOutFromSent()) {
        return [];
    }

    return [SentChannel::class];
}

Number lookup

Look up carrier information for any phone number. Results are cached:

$result = Sent::lookup('+61412345678');

$result->data->isValid;       // bool
$result->data->carrierName;   // 'Telstra'
$result->data->lineType;      // 'mobile', 'landline', 'voip'
$result->data->isVoip;        // bool
$result->data->isPorted;      // bool
$result->data->countryCode;   // 'AU'

From the command line:

php artisan sent:lookup +61412345678

Phone number validation

Validate E.164 format and optionally verify the number against the Sent.dm lookup API. Fails open if the API is unreachable — a network blip never blocks a valid form submission.

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

class SendMessageRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'phone' => ['required', Rule::sentMobileNumber()],
        ];
    }
}

Require a mobile line (reject landlines and VoIP):

'phone' => ['required', Rule::sentMobileNumber(requireMobile: true)],

Multi-tenant connections

Define one connection per Sent.dm API key in config/sent.php:

'connections' => [
    'default' => [
        'api_key' => env('SENT_API_KEY'),
    ],
    'acme' => [
        'api_key' => env('SENT_ACME_API_KEY'),
    ],
    'globex' => [
        'api_key' => env('SENT_GLOBEX_API_KEY'),
    ],
],

Switch at runtime:

// send via the default connection
Sent::to('+61412345678')->template('otp')->send();

// send via a named connection
Sent::connection('acme')->to('+61412345678')->template('otp')->send();

// bulk via a named connection
Sent::connection('acme')->bulk($numbers)->template('promo')->dispatch();

App-level pattern — resolve connection from the authenticated tenant

// app/Http/Middleware/ResolveSentConnection.php
class ResolveSentConnection
{
    public function handle(Request $request, Closure $next): mixed
    {
        $tenant = $request->user()?->tenant;

        if ($tenant) {
            // the connection key matches the tenant slug configured in sent.php
            app()->instance('sent.connection', $tenant->slug);
        }

        return $next($request);
    }
}

// Usage anywhere in the app
$connection = app('sent.connection', 'default');
Sent::connection($connection)->to($user->phone)->template('otp')->send();

App-level pattern — custom driver

Register a completely custom driver if you need to override how the SDK client is built:

// app/Providers/AppServiceProvider.php
use Sujip\SentDm\SentManager;

app(SentManager::class)->extend('custom', function () {
    return new \Sujip\SentDm\Sent(
        client: new \SentDm\Client(apiKey: 'custom-key'),
    );
});

Contacts API

// list — chainable query builder
Sent::contacts()->get();
Sent::contacts()->search('John')->channel('whatsapp')->page(2)->perPage(25)->get();

// read (cached)
Sent::contacts()->find('contact_id');

// create
Sent::contacts()->create()->phone('+61412345678')->save();
Sent::contacts()->create()->phone('+61412345678')->defaultChannel('sms')->save();

// update (invalidates cache)
Sent::contacts()->update('contact_id')->defaultChannel('whatsapp')->save();
Sent::contacts()->update('contact_id')->optOut(true)->save();

// delete (invalidates cache)
Sent::contacts()->delete('contact_id');

Templates API

// list (cached per page)
Sent::templates()->get();
Sent::templates()->page(2)->perPage(25)->get();

// filter by category (MARKETING, UTILITY, AUTHENTICATION)
Sent::templates()->category('MARKETING')->get();

// filter by status (APPROVED, PENDING, REJECTED)
Sent::templates()->status('APPROVED')->get();

// filter by welcome playground flag
Sent::templates()->isWelcomePlayground()->get();

// read (cached)
Sent::templates()->find('template_id');
Sent::templates()->findByName('otp-verification');

// create
Sent::templates()->create()
    ->category('UTILITY')
    ->language('en_US')
    ->definition(['body' => [...]])
    ->save();

// create and submit for review immediately
Sent::templates()->create()
    ->category('MARKETING')
    ->definition(['body' => [...]])
    ->submitForReview()
    ->save();

// update (invalidates cache)
Sent::templates()->update('template_id')
    ->name('new-name')
    ->category('UTILITY')
    ->save();

// delete
Sent::templates()->delete('template_id');

From the command line:

php artisan sent:templates
php artisan sent:templates --page=2 --per-page=25

Webhooks API

Manage webhook endpoints from code, beyond just receiving events:

// list
Sent::webhooks()->get();
Sent::webhooks()->page(2)->perPage(10)->get();

// read
Sent::webhooks()->find('webhook_id');

// create
Sent::webhooks()->create()
    ->url('https://yourapp.com/sent/webhook')
    ->events(['message.delivered', 'message.failed'])
    ->save();

// update
Sent::webhooks()->update('webhook_id')
    ->url('https://yourapp.com/new-path')
    ->save();

// enable / disable
Sent::webhooks()->enable('webhook_id');
Sent::webhooks()->disable('webhook_id');

// rotate the signing secret
Sent::webhooks()->rotateSecret('webhook_id');

// send a test event to the endpoint
Sent::webhooks()->test('webhook_id');
Sent::webhooks()->test('webhook_id', 'message.delivered');

// list delivery events for an endpoint (paginated)
Sent::webhooks()->listEvents('webhook_id');
Sent::webhooks()->listEvents('webhook_id', page: 2, pageSize: 25);

// list all supported event types (cached)
Sent::webhooks()->listEventTypes();

// delete
Sent::webhooks()->delete('webhook_id');

Profiles API

// list (cached)
Sent::profiles()->get();

// read
Sent::profiles()->find('profile_id');

// create
Sent::profiles()->create()
    ->name('Sales Team')                   // required
    ->shortName('SALES')                   // 3–11 chars
    ->description('Outbound sales')
    ->billingModel('organization')         // 'organization' | 'profile' | 'profile_and_organization'
    ->inheritContacts(true)
    ->inheritTemplates(true)
    ->inheritTcrBrand(true)
    ->inheritTcrCampaign(true)
    ->allowContactSharing(false)
    ->allowTemplateSharing(false)
    ->icon('https://example.com/logo.png')
    ->billingContact([...])                // required when billingModel is 'profile'
    ->brand([...])                         // brand + KYC data
    ->paymentDetails([...])                // card details forwarded to payment processor
    ->whatsappBusinessAccount([...])       // direct WABA credentials from Meta
    ->save();

// update — all fields optional; also exposes sending number overrides
Sent::profiles()->update('profile_id')
    ->name('Support Team')
    ->inheritTemplates(true)
    ->allowNumberChangeDuringOnboarding(true)
    ->sendingPhoneNumber('+61412345678')
    ->sendingPhoneNumberProfileId('other_profile_id')
    ->sendingWhatsappNumberProfileId('other_profile_id')
    ->whatsappPhoneNumber('+61412345678')
    ->save();

// complete profile onboarding (runs in background, calls your webhook when done)
Sent::profiles()->complete('profile_id', 'https://yourapp.com/hooks/profile-complete');

// delete
Sent::profiles()->delete('profile_id');

Campaigns sub-resource

Manage TCR campaigns scoped to a profile:

$campaigns = Sent::profiles()->campaigns('profile_id');

// list
$campaigns->get();

// create
$campaigns->create([
    'name'        => 'OTP Verification',
    'description' => 'One-time passcode delivery',
    'type'        => 'KYC',
    'useCases'    => [
        ['usecase' => 'OTP', 'sample' => 'Your code is {{code}}.'],
    ],
]);

// update
$campaigns->update('campaign_id', [
    'name'        => 'OTP v2',
    'description' => 'Updated OTP campaign',
    'type'        => 'KYC',
    'useCases'    => [
        ['usecase' => 'OTP', 'sample' => 'Your verification code is {{code}}.'],
    ],
]);

// delete
$campaigns->delete('campaign_id');

Users API

// list
Sent::users()->get();

// read
Sent::users()->find('user_id');

// invite
Sent::users()->invite()
    ->email('alice@example.com')
    ->name('Alice')
    ->role('member')
    ->save();

// update role (admin, billing, developer)
Sent::users()->updateRole('user_id', 'admin');

// remove
Sent::users()->remove('user_id');

Messages API

Check the status of a sent message or retrieve its activity log by message ID:

// get current delivery status
$status = Sent::messages()->retrieve('msg_abc123');
$status->data->messageStatus; // 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', etc.

// get activity log (all events for the message)
$activities = Sent::messages()->activities('msg_abc123');

Message IDs are returned in the MessageSent event and stored in sent_logs.message_id when logging is enabled.

Account

$account = Sent::account();

$account->data->type;    // 'organization', 'user', or 'profile'
$account->data->name;
$account->data->email;
$account->data->channels->sms->configured;       // bool
$account->data->channels->whatsapp->configured;  // bool

Check account health from the command line:

php artisan sent:health
php artisan sent:health --connection=acme

Artisan commands

Command Description
sent:install Publish config/sent.php
sent:health Check API connectivity and account status
sent:test-send {number} --template= Send a test message
sent:templates List templates in a table
sent:lookup {number} Carrier lookup for a phone number
sent:setup-webhook {url} Create a webhook endpoint on Sent.dm
sent:stats Show aggregate message counts from the local sent_logs table (not from the Sent.dm API — requires logging migration)

All commands accept --connection= to target a named connection.

# test a send in sandbox mode
php artisan sent:test-send +61412345678 --template=otp --sandbox

# check a named tenant connection
php artisan sent:health --connection=acme

# create a webhook for specific events
php artisan sent:setup-webhook https://yourapp.com/sent/webhook \
    --events=message.delivered \
    --events=message.failed

# show local message stats (requires logging migration)
php artisan sent:stats
php artisan sent:stats --table=custom_logs_table

Testing

Use Sent::fake() at the start of any test. It replaces the real driver with an in-memory recorder and gives you full assertions — no real API calls, no queued jobs.

use Sujip\SentDm\Facades\Sent;

beforeEach(fn () => Sent::fake());

it('sends a welcome message on user registration', function () {
    $user = User::factory()->create(['phone' => '+61412345678']);

    $user->sendWelcomeMessage();

    Sent::assertSentTo('+61412345678');
    Sent::assertSentCount(1);
});

Sent assertions

// assert by recipient
Sent::assertSentTo('+61412345678');

// assert by recipient with a callback
Sent::assertSentTo('+61412345678', function (SentMessage $message) {
    return $message->getTemplateName() === 'welcome';
});

// assert by template
Sent::assertSentWithTemplate('otp');

// assert by template with a callback
Sent::assertSentWithTemplate('otp', function (SentMessage $message) {
    return $message->getTemplateData()['code'] === '123456';
});

// assert with a custom callback
Sent::assertSent(function (SentMessage $message) {
    return $message->getChannel() === 'sms';
});

// count and negative assertions
Sent::assertSentCount(2);
Sent::assertNothingSent();

Queued assertions

// assert queued via sendLater()
Sent::assertQueuedTo('+61412345678');

Sent::assertQueuedTo('+61412345678', function (SentMessage $message) {
    return $message->getTemplateName() === 'order-shipped';
});

Sent::assertQueuedCount(3);
Sent::assertNothingQueued();

Multi-tenant assertions

Sent::assertSentViaConnection('acme');

Sent::assertSentViaConnection('acme', function (SentMessage $message) {
    return $message->getRecipient() === '+61412345678';
});

Sent::assertQueuedViaConnection('globex');

Introspection

$sent   = Sent::sent();    // list<SentMessage>
$queued = Sent::queued();  // list<SentMessage>

Sent::hasSent();    // bool
Sent::hasQueued();  // bool
Sent::reset();      // clear records between tests

Testing opt-out behaviour

The HasSentContact opt-out methods hit the database. Use RefreshDatabase and create an opt-out record directly:

use Sujip\SentDm\Models\SentOptOut;

it('skips send when user has opted out', function () {
    Sent::fake();

    $user = User::factory()->create(['phone' => '+61412345678']);
    SentOptOut::create(['phone_number' => '+61412345678', 'opted_out' => true]);

    $user->sendWelcomeMessage(); // should check optedOutFromSent() and skip

    Sent::assertNothingSent();
});

Sponsoring

Sponsor

If this package has been useful to you, GitHub Sponsors is a simple way to support ongoing maintenance, improvements, and future releases.

Contributing

Contributions are welcome. Please open an issue to discuss what you'd like to change, or submit a pull request directly for bug fixes and small improvements. Make sure composer test, composer stan, and composer lint:check all pass before submitting.

License

This package is open source, licensed under the MIT license.