moonlydays / php-kannel
PHP client for the Kannel SMS gateway — sendsms, admin, and inbound DLR/MO handling.
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.8
- guzzlehttp/psr7: ^2.6
- psr/event-dispatcher: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^2.0 || ^3.0
Requires (Dev)
- laravel/pint: ^1.17
- nyholm/psr7: ^1.8
- orchestra/testbench: ^9.0
- pestphp/pest: ^3.5
- phpstan/phpstan: ^1.11
- phpstan/phpstan-strict-rules: ^1.6
- rector/rector: ^1.2
- symfony/psr-http-message-bridge: ^7.0
Suggests
- illuminate/notifications: Required for the Kannel notification channel.
- illuminate/support: Required for the Laravel service provider, facade, and notification channel (auto-discovered on Laravel 11+).
- nyholm/psr7: A lightweight PSR-17 factory; pairs with symfony/psr-http-message-bridge for inbound routes.
- symfony/psr-http-message-bridge: Required to expose Kannel inbound (DLR/MO) webhook routes in a Laravel app.
README
A PSR-compliant PHP client for the Kannel SMS gateway. Handles outbound SMS via sendsms, bearerbox admin commands, and inbound DLR/MO callback parsing — with typed value objects, enums, and a clean extensibility surface.
Features
- Send SMS via Kannel's
sendsmsHTTP interface (GET or POST) - Bearerbox admin API — typed methods for
status,suspend,resume,shutdown,log-level,add-smsc,remove-smsc, and more - Inbound DLR parsing — PSR-7
ServerRequest→ typedDeliveryReport - Inbound MO parsing — PSR-7
ServerRequest→ typedIncomingMessage - Multi-gateway routing via
GatewayRegistry - PSR-18 HTTP client (Guzzle as the default, swappable)
- PSR-17 factories, PSR-14 events, PSR-3 logging — all optional
- FakeClient test double for consumer-side tests
- Readonly value objects, backed enums, strict types throughout
Requirements
- PHP 8.2+
- A reachable Kannel installation (for sendsms and/or admin)
Installation
composer require moonlydays/php-kannel
Quick start
use MoonlyDays\Kannel\Client; use MoonlyDays\Kannel\Enum\HttpMethod; use MoonlyDays\Kannel\Gateway; use MoonlyDays\Kannel\Message\SmsBuilder; $gateway = new Gateway( name: 'default', sendSmsUrl: 'https://kannel.example.com/cgi-bin/sendsms', username: 'user', password: 'pass', httpMethod: HttpMethod::Post, ); $client = new Client($gateway); $sms = (new SmsBuilder()) ->to('+14155550123') ->from('MyBrand') ->text('Your verification code is 123456') ->build(); $result = $client->send($sms); if ($result->isAccepted()) { // $result->status, $result->kannelMessageId, $result->rawBody }
Configuration
Gateway
Gateway is an immutable value object carrying everything needed to talk to one Kannel installation.
use MoonlyDays\Kannel\AdminCredentials; use MoonlyDays\Kannel\Enum\HttpMethod; use MoonlyDays\Kannel\Gateway; $gateway = new Gateway( name: 'primary', sendSmsUrl: 'https://kannel.example.com/cgi-bin/sendsms', username: 'user', password: 'pass', httpMethod: HttpMethod::Post, admin: new AdminCredentials( // optional — only if using admin API url: 'http://kannel.example.com:13000', password: 'admin-secret', ), defaultSmscId: 'operator-a', // optional per-gateway default defaultDlrUrl: 'https://your.app/kannel/dlr', // optional fallback dlr-url timeoutSeconds: 30, );
The httpMethod choice is deliberate — the library doesn't pick for you. Use Post when you want credentials out of access logs; Get when Kannel is configured to accept only GET.
Multi-gateway
use MoonlyDays\Kannel\GatewayRegistry; $registry = new GatewayRegistry( gateways: [$primary, $fallback, $operatorA], default: 'primary', ); $client = new Client($registry); $client->send($sms); // default gateway $client->send($sms, via: 'operatorA'); // named gateway
Single-gateway users don't see the registry — new Client($gateway) normalizes internally.
Swapping the HTTP client
Every PSR dependency is optional. Pass your own to override the Guzzle defaults.
$client = new Client( gateway: $gateway, httpClient: $yourPsr18Client, requestFactory: $yourPsr17Factory, streamFactory: $yourPsr17Factory, dispatcher: $yourPsr14Dispatcher, logger: $yourPsr3Logger, );
Sending messages
SmsBuilder is the only supported way to construct an Sms. It validates invariants (recipient required, text XOR binary) on build().
use MoonlyDays\Kannel\Enum\Coding; use MoonlyDays\Kannel\Enum\DlrEvent; use MoonlyDays\Kannel\Enum\MessageClass; $sms = (new SmsBuilder()) ->to('+14155550123') ->from('ALERT') ->text('Héllo 👋') ->coding(Coding::Ucs2) // GSM-7 | Binary | UCS-2 ->messageClass(MessageClass::Flash) // or ->flash() shorthand ->dlrMask(DlrEvent::Delivered, DlrEvent::DeliveryFailure, DlrEvent::SmscRejected) ->dlrUrl('https://your.app/kannel/dlr?msgid=abc-123') ->smsc('operator-a') ->priority(1) ->validity(1440) // minutes ->deferred(5) // minutes ->metaData('smpp', 'service_type', 'VERIFY') ->build();
Sending results
$result = $client->send($sms); $result->status; // SendStatus enum — Accepted | Queued | Malformed | ... $result->httpStatusCode; // raw HTTP status from Kannel $result->rawBody; // raw response body, e.g. "0: Accepted for delivery" $result->kannelMessageId; // extracted when present in the body $result->isAccepted(); $result->isQueued(); $result->isSuccess(); // Accepted or Queued
Delivery reports (DLR)
Configuring dlr-url
Kannel substitutes placeholders (%d, %p, %T, %I, …) in your callback URL before hitting it. The library gives you two ways to build the URL — explicit or via a binding.
Explicit — full control over query key names:
use MoonlyDays\Kannel\Dlr\DlrUrl; $dlrUrl = (new DlrUrl('https://your.app/kannel/dlr')) ->withQuery('msgid', 'abc-123') // literal value ->withStatus('status') // binds to %d ->withPhone('to') // binds to %p ->withTimestamp('ts') // binds to %T ->withSmscMessageId('smsid') // binds to %I ->build(); $sms = (new SmsBuilder()) ->to('+14155550123') ->text('hello') ->dlrUrl($dlrUrl) ->dlrMask(DlrEvent::Delivered, DlrEvent::DeliveryFailure) ->build();
Convention-based — shared with the inbound parser:
use MoonlyDays\Kannel\Inbound\DlrBinding; $binding = new DlrBinding(); // sensible defaults $dlrUrl = $binding->toDlrUrl('https://your.app/kannel/dlr'); $sms = (new SmsBuilder()) ->to('+14155550123') ->text('hello') ->dlrUrl($dlrUrl) ->dlrMask(DlrEvent::Delivered, DlrEvent::DeliveryFailure) ->build();
The same $binding is then passed to InboundHandler on the receiving side, so the two halves agree on query key names without duplicated string literals.
Receiving DLR callbacks
use MoonlyDays\Kannel\Inbound\InboundHandler; $inbound = new InboundHandler(); // default bindings // — or — $inbound = new InboundHandler( dlrBinding: $binding, dispatcher: $psr14Dispatcher, // fires DlrReceived event ); // Inside your PSR-7 controller / middleware: public function handleDlr(ServerRequestInterface $request): ResponseInterface { $dlr = $inbound->receiveDlr($request); // DeliveryReport { // status: DlrEvent, // recipient, sender, smscId, smscMessageId, // receivedAt: ?DateTimeImmutable, // replyText, // raw: array // all query/body params, for custom fields // } // persist, update order state, whatever return new Response(200); }
The handler reads both query params and parsed body, so it works regardless of whether Kannel was configured to POST or GET the callback.
Mobile originated messages (MO)
use MoonlyDays\Kannel\Inbound\InboundHandler; use MoonlyDays\Kannel\Inbound\MoBinding; $inbound = new InboundHandler( moBinding: new MoBinding(), // or customize keys to match your sms-service config dispatcher: $psr14Dispatcher, // fires MoReceived event ); public function handleMo(ServerRequestInterface $request): ResponseInterface { $mo = $inbound->receiveMo($request); // IncomingMessage { // from, to, text, binary, udh, // coding: ?Coding, // smscId, receivedAt, raw // } return new Response(200); }
Admin API
Requires the gateway to have AdminCredentials attached. Each command maps to a typed method.
use MoonlyDays\Kannel\Admin\AdminClient; use MoonlyDays\Kannel\Enum\LogLevel; $admin = new AdminClient($gateway); $status = $admin->status(); // GatewayStatus DTO parsed from status.xml $text = $admin->statusText(); // raw plain-text status $admin->suspend(); $admin->isolate(); $admin->resume(); $admin->flushDlr(); $admin->reloadLists(); $admin->setLogLevel(LogLevel::Debug); $admin->removeSmsc('operator-a'); $admin->addSmsc('operator-a'); $admin->shutdown(); $admin->restart();
GatewayStatus exposes version, uptime string, SMS counters, DLR queue depth, a list of SmscStatus and a list of BoxStatus.
Constructing an AdminClient on a gateway without admin credentials throws ConfigurationException.
Events (PSR-14)
Pass any PSR-14 dispatcher to receive events:
$client = new Client($gateway, dispatcher: $dispatcher); $inbound = new InboundHandler(dispatcher: $dispatcher);
| Event | Dispatched by | Fired |
|---|---|---|
MessageSending |
Client::send() |
Before the transport runs |
MessageSent |
Client::send() |
After the transport returns (success or Kannel err) |
MessageFailed |
Client::send() |
When the transport throws |
DlrReceived |
InboundHandler |
After receiveDlr() parses successfully |
MoReceived |
InboundHandler |
After receiveMo() parses successfully |
All events are readonly DTOs carrying the relevant Sms / Gateway / SendResult / DeliveryReport / IncomingMessage.
Logging (PSR-3)
$client = new Client($gateway, logger: $logger);
The client emits:
debugwhen a send beginsinfoafter a response is parsederrorwhen the transport throws
Testing
FakeClient is a drop-in ClientInterface implementation that records sends and returns configurable results.
use MoonlyDays\Kannel\Testing\FakeClient; use MoonlyDays\Kannel\Enum\SendStatus; use MoonlyDays\Kannel\Http\SendResult; $fake = new FakeClient(); $service->sendWelcomeSms($user, $fake); expect($fake->count())->toBe(1); expect($fake->sent()[0]->sms->to)->toBe('+14155550123'); expect($fake->sent()[0]->via)->toBeNull(); expect($fake->messages()[0]->text)->toStartWith('Welcome');
Configure per-gateway failure to test fallback logic:
$fake->willReturnFor('broken', new SendResult( status: SendStatus::InternalError, httpStatusCode: 500, rawBody: '4: Internal error', )); $ok = $fake->send($sms); // uses default (accepted) $bad = $fake->send($sms, via: 'broken'); // returns the configured failure
Running the package's own tests
composer test # full suite (Pest) composer test:unit # unit tests only composer test:feature # feature tests only composer stan # PHPStan at level max + strict rules composer pint # code style composer rector:dry # Rector dry-run
Exception handling
Every exception thrown by this package implements MoonlyDays\Kannel\Exception\ExceptionInterface, so you can catch them all with one type:
use MoonlyDays\Kannel\Exception\ExceptionInterface; try { $client->send($sms); } catch (ExceptionInterface $e) { // library-level error }
| Exception | Meaning |
|---|---|
TransportException |
HTTP-level failure (network, DNS, TLS) reaching Kannel |
AdminCommandFailedException |
Admin command returned 4xx/5xx |
MalformedResponseException |
status.xml unparseable, DLR missing required keys, unknown status code |
ConfigurationException |
Package misused (no recipient, missing admin creds, etc.) |
GatewayNotFoundException |
Registry lookup failed |
Kannel answering with a non-success status (e.g. 3: Malformed data) is not an exception — it comes back as a SendResult with a non-success SendStatus.
License
MIT — see LICENSE.