ux2dev/link-mobility
LINK Mobility MessageHub. SMS, Viber, Push and Voice/TTS. Delivery reports and two-way callbacks parsed into typed objects.
A framework-agnostic PHP client for the LINK Mobility MessageHub API with an optional, opinionated Laravel integration. Send SMS, Viber (text + templates), Push and Voice/TTS messages; query delivery status; manage Viber templates; and parse inbound callbacks (delivery reports, two-way/MO messages, template approvals) into typed objects - with optional Eloquent persistence.
- Core (
Ux2Dev\LinkMobility\): no Laravel required, built on PSR-18 / PSR-17. - Laravel layer (
Ux2Dev\LinkMobility\Laravel\): facade, callback route, events, opt-in queued persistence, models, migrations, and Artisan commands.
Requirements
- PHP
^8.1,ext-openssl,ext-json - A PSR-18 HTTP client + PSR-17 factories (e.g.
guzzlehttp/guzzle ^7)
Installation
composer require ux2dev/link-mobility
For Laravel, publish the config (and optionally the migrations / routes):
php artisan vendor:publish --tag=link-mobility-config
php artisan vendor:publish --tag=link-mobility-migrations # optional persistence
php artisan vendor:publish --tag=link-mobility-routes # optional custom routes
Configuration
Set these in .env:
LINK_MOBILITY_ENV=production # or "testing"
LINK_MOBILITY_API_KEY=your-api-key
LINK_MOBILITY_API_SECRET=your-api-secret
LINK_MOBILITY_SERVICE_ID=1 # optional default service_id
LINK_MOBILITY_SC=YourSender # optional default sender / shortcode
# Optional webhook protection (see "Callbacks" below)
LINK_MOBILITY_WEBHOOK_SECRET=
LINK_MOBILITY_WEBHOOK_TOKEN=
# Optional persistence
LINK_MOBILITY_PERSIST=false
Authentication is automatic: every request is signed with
x-api-sign = HMAC-SHA512(rawJsonBody, api_secret) and sent with your x-api-key.
Sending messages
Laravel (facade)
use Ux2Dev\LinkMobility\Laravel\Facades\LinkMobility;
use Ux2Dev\LinkMobility\Message\SmsMessage;
$result = LinkMobility::send(
SmsMessage::to('359888123456')
->text('Hello from MessageHub')
->callbackUrl(route('link-mobility.callback'))
);
$result->smsId; // "light-5b83e6df35fe41.14520017"
$result->requestId;
$result->isOk(); // meta.code === 200
Framework-agnostic core
use GuzzleHttp\Client as Guzzle;
use GuzzleHttp\Psr7\HttpFactory;
use Ux2Dev\LinkMobility\Client;
use Ux2Dev\LinkMobility\Config\Config;
use Ux2Dev\LinkMobility\Enum\Environment;
use Ux2Dev\LinkMobility\Message\SmsMessage;
$factory = new HttpFactory();
$client = new Client(
new Config('api-key', 'api-secret', Environment::Production, defaultServiceId: '1'),
new Guzzle(),
$factory, // PSR-17 request factory
$factory, // PSR-17 stream factory
);
$result = $client->send(SmsMessage::to('359888123456')->text('Hi'));
Channels
use Ux2Dev\LinkMobility\Message\{SmsMessage, ViberMessage, PushMessage, TtsMessage};
use Ux2Dev\LinkMobility\Enum\Priority;
// SMS
SmsMessage::to('359888123456')->text('Hi')
->priority(Priority::High)->delay('+11 hours')->unique();
// Viber text (with SMS fallback)
ViberMessage::to('359888123456')->sc('ViberTest')->text('Viber message')
->ttl(120)->platform(1)->fallbackSms('SMS fallback');
// Viber template
ViberMessage::to('359888123456')->sc('ViberTest')
->template('6c929cef-29b4-4349-bc9d-2a07bdbb6e43', ['pin' => '123456'], 'bg')
->fallbackSms('Your code: 123456');
// Push
PushMessage::create()->sc('PushTest')
->title('Title')->body('Body')->uid('cloud.msghub.example_dfXXXXX')
->redirectUrl('https://example.com')->fallbackSms('SMS fallback');
// Voice / Text-to-Speech
TtsMessage::to('359888123456')->sc('VoiceTest')->text('Spoken message')
->fallbackSms('SMS fallback');
For Viber rich content (carousels, surveys, buttons) not yet modelled as typed
builders, use ViberMessage::rich([...]) to merge a structured array into the
payload.
Batch send & delivery status
$results = LinkMobility::sendMany($msgA, $msgB, $msgC); // array<SendResult>
$dlr = LinkMobility::dlr('light-5b83e6df35fe41.14520017');
$dlr->status; // DlrStatus enum (Delivered, Undelivered, ...)
$dlr->status?->isDelivered();
Viber templates
$templates = LinkMobility::viberTemplates();
$templates->create([...]); // POST /viber_templates/create
$templates->get([...]); // POST /viber_templates/get
$templates->delete([...]); // POST /viber_templates/delete
Callbacks (delivery reports, two-way messages, template approvals)
Point your callback_url (and LINK Mobility's MO / template webhooks) at the
package route: POST /{prefix}/callback (default prefix link-mobility, so
/link-mobility/callback). The controller parses the payload, dispatches events,
and - for MO - can return a synchronous reply.
Webhook authentication
Verification is opt-in and fail-closed:
- Set
LINK_MOBILITY_WEBHOOK_SECRETto require a validx-api-signHMAC-SHA512 over the raw body on every callback. Missing or invalid ⇒401. - Otherwise set
LINK_MOBILITY_WEBHOOK_TOKENand configure your callback URLs with?token=<value>. - With neither set, the endpoint is unauthenticated (dev only) - secure it before production.
Events
| Event | Fired for |
|---|---|
DeliveryReportReceived |
every DLR callback |
MessageDelivered |
DLR with status = delivered |
MessageUndelivered |
DLR in a final non-delivered state |
InboundMessageReceived |
MO / two-way inbound message |
TemplateStatusChanged |
Viber template approval/decline |
use Ux2Dev\LinkMobility\Laravel\Events\MessageDelivered;
Event::listen(MessageDelivered::class, function (MessageDelivered $e) {
$e->report->smsId;
$e->report->status; // DlrStatus
});
Two-way replies
Bind an InboundMessageHandler; its return value becomes the synchronous reply
LINK Mobility sends back to the sender (return null for no reply):
use Ux2Dev\LinkMobility\Callback\Contracts\InboundMessageHandler;
use Ux2Dev\LinkMobility\Callback\InboundMessage;
$this->app->bind(InboundMessageHandler::class, fn () => new class implements InboundMessageHandler {
public function handle(InboundMessage $message): ?string
{
return "You said: {$message->text}";
}
});
Parsing callbacks without Laravel
The parser is part of the framework-agnostic core:
use Ux2Dev\LinkMobility\Callback\CallbackParser;
$event = CallbackParser::parse($decodedJsonBody);
// => DeliveryReport | InboundMessage | TemplateStatusUpdate
Optional persistence
Ship-and-forget storage for callbacks. Publish + run the migrations, then enable it:
LINK_MOBILITY_PERSIST=true
When enabled, queued listeners write to four tables/models
(OutboundMessage, DeliveryReport, InboundMessage, ViberTemplate).
Delivery reports link back to outbound messages by sms_id. Everything is
optional - the package is fully functional without the database.
Artisan commands
php artisan link-mobility:status # show resolved config + base URL
php artisan link-mobility:send-test 359888123456 --text="Hello"
Exceptions
All extend Ux2Dev\LinkMobility\Exception\LinkMobilityException:
| Exception | Meaning |
|---|---|
ApiException |
Non-2xx HTTP or meta.code >= 400 (exposes metaCode, metaText, requestId, httpStatus) |
TransportException |
PSR-18 client / network failure |
SignatureException |
Empty signing secret |
ConfigurationException |
Missing/invalid credentials |
InvalidResponseException |
Malformed JSON |
UnrecognizedCallbackException |
Callback payload not identifiable as DLR / MO / template |
Testing & static analysis
composer install
composer test # Pest test suite
composer stan # PHPStan (level max, via Larastan)
composer rector # Rector - preview refactors (dry-run)
composer check # stan + test
The shipped code under src/ is analysed at PHPStan level max and is clean.
License
MIT. See LICENSE.