sudiptpa / laravel-sent-dm
An expressive Laravel adapter for the Sent.dm unified messaging API — SMS, WhatsApp and RCS with a fluent, elegant interface.
Fund package maintenance!
Requires
- php: ^8.2
- illuminate/bus: ^11.0|^12.0|^13.0
- illuminate/cache: ^11.0|^12.0|^13.0
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/events: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/notifications: ^11.0|^12.0|^13.0
- illuminate/queue: ^11.0|^12.0|^13.0
- illuminate/routing: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- sentdm/sent-dm-php: ^0.23
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0
- phpstan/extension-installer: ^1.0
- phpstan/phpstan: ^2.0
README
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-Afterdelay, 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
MailandCache; switch accounts per request withSent::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
- Testing —
Sent::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
MessageFailedand 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.receivedwebhook events) do not create asent_logsrecord — 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
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.