moonlydays/php-kannel

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

Maintainers

Package info

github.com/MoonlyDays/php-kannel

pkg:composer/moonlydays/php-kannel

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.0.1 2026-04-23 20:22 UTC

This package is auto-updated.

Last update: 2026-04-27 07:12:07 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.