moonlydays/php-kannel

PHP client for the Kannel SMS gateway — sendsms, admin, and inbound DLR/MO handling.

Maintainers

👁 MoonlyDays

Package info

github.com/MoonlyDays/php-kannel

pkg:composer/moonlydays/php-kannel

Statistics

Installs: 7

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.0.1 2026-04-23 20:22 UTC

Requires

Suggests

Provides

None

Conflicts

None

Replaces

None

MIT e7df7dff87b84c86d60d5eecb0fa761dc2a9bb65

  • MoonlyDays <moonlydays1.woop@gmail.com>

smsgatewaymosmsckannelpsr-14psr-18dlr

This package is auto-updated.

Last update: 2026-06-27 07:32:34 UTC


README

👁 Tests
👁 Latest Version on Packagist
👁 License

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 sendsms HTTP 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 → typed DeliveryReport
  • Inbound MO parsing — PSR-7 ServerRequest → typed IncomingMessage
  • 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:

  • debug when a send begins
  • info after a response is parsed
  • error when 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.