Writing an SMS Module
An SMS module hands a text message to a gateway. Unlike mail it does three jobs: notifications, international sending and, sometimes, price and delivery reporting.
Overview
There is no base class. The contract is duck typed like Mail: core loads the class named after the module directory and calls a known set of methods.
Two config keys point at drivers: modules/sms for notifications, modules/sms-intl for international traffic. A domestic gateway passes foreign numbers to the second, so every driver keeps two buckets.
A third path is module local: your controllers/ files may build the driver with unsaved credentials.
Prerequisites
- Write access to
coremio/modules/SMS/. - Gateway credentials and usually a registered sender ID.
- Is your gateway domestic only, or international? That drives the capability flags.
- Writing a Mail Module.
Structure
Directory, file and class name are the same string, no namespace.
coremio/modules/SMS/Acme/
├── Acme.php the driver class, named Acme, no namespace
├── config.php meta (with the international flag) + saved credentials
├── Source/class.php optional gateway client, included by the constructor
├── lang/en.php
├── lang/tr.php
└── logo.png optional, referenced from config meta.logo
Walkthrough
Building the Driver Class
- Create
coremio/modules/SMS/Acme/Acme.phpwith the guard andclass Acme. - Declare the capability flags as public properties.
- In the constructor, merge the saved config with the passed array, which wins.
- Implement
body(),title(),AddNumber(), each returning$this. - Implement
submit(): send the domestic bucket, pass the international one on. - Implement
getTitle(),getBody(),getNumbers(),getError().
Sender ID and Numbers
- Seed the sender from config in the constructor.
- In
AddNumber(), accept three shapes: a number, a number plus country code, an array of either. - A value with a pipe is
countryCode|number. - Route into a bucket by country code, unless the caller forbids the handoff.
- Reset both buckets inside
body().
Settings and Reports
- Add
page_settings()with the hidden fields, enable checkbox and credentials. - Add
controller_save(): write changed values intoconfig.php, flipmodules/sms. - Implement
getBalance()for a credit balance; core never calls it. - Implement
getReportID()andgetReport()for a batch id. - Implement
get_prices()only for an international gateway. It feeds the country price table and the pricing cron.
Reference
What Core Calls
The dispatcher composes the message, then walks the recipients.
| Method | When core calls it | Must return |
|---|---|---|
__construct($external_config = []) | Once per dispatch | nothing |
body($text, $template, $variables, $lang, $user) | First; resets buckets | $this |
title($arg) | On a sender ID override | $this |
AddNumber($arg, $cc) | Per recipient, or with an array | $this |
submit($isthis = false) | After the recipients are in | truthy, or false on failure |
getTitle() | After a good submit | string |
getBody() | After a good submit | string |
getNumbers() | After a good submit | both buckets, one array |
getError() | After a falsy submit | the failure text |
numbers_reset() | By your body() | not read |
getReportID(), getReport($id), get_prices() | Where offered | see below |
getBalance() | Never by core | your own shape |
Note the casing: the dispatcher writes addNumber(), drivers declare AddNumber(). PHP resolves both.
Method Signatures
public function __construct($external_config = []);
// Resets BOTH recipient buckets, then renders the template when one is given.
// $template is "group/name", e.g. "user/gsm-activation"; false means $text is final.
public function body($text = '', $template = false, $variables = [], $lang = '', $user = 0);
// The sender ID / originator shown on the handset.
public function title($arg = '');
// ORDER TRAP: the number is FIRST, the country code is SECOND.
// $arg accepts "5551234567", "90|5551234567", or an array of either shape.
// $cc is only consulted when $arg is not an array.
public function AddNumber($arg = 0, $cc = null);
// $isthis = true returns the driver instead of the boolean.
public function submit($isthis = false);
public function getTitle();
public function getBody();
public function getNumbers();
public function getError();
public function numbers_reset();
// Optional, feature by feature. Core probes each with method_exists() before calling.
public function getReportID(); // the batch id produced by the last submit
public function getReport($id = 0); // delivery report for a batch
public function get_prices(); // international price list, see below
// Module-local convention: core never calls this. It is reached only from your own
// controllers/*.php and pages/*.php, so the argument list is yours.
public function getBalance(); // remaining credit, or false with $error set
Two return shapes are read by name:
// getReport(): three named buckets, each with the raw rows and a count.
// The report reader also accepts 'delivered' / 'sending' / 'failed' as aliases.
return [
'waiting' => ['data' => $waitingRows, 'count' => count($waitingRows)],
'conducted' => ['data' => $deliveredRows, 'count' => count($deliveredRows)],
'erroneous' => ['data' => $failedRows, 'count' => count($failedRows)],
];
// get_prices(): country code => currency code => cost per message.
// The first usable currency wins unless config's supported-currencies names one.
return [
'TR' => ['EUR' => 0.0180],
'DE' => ['EUR' => 0.0640, 'USD' => 0.0700],
'US' => ['USD' => 0.0075],
];
Costs from get_prices() are exchanged into the primary currency, the sms/profit-rate margin is added, and the country map is rewritten.
Flags
Public properties, not methods. Core reads them on the instance and in the config, so both must agree.
AddNumber() and submit().
body().
getError() exists.
config.php, used by the international driver picker. Property and meta must agree.
Config Keys
name key wins.
title() from it.
get_prices() offers several. Empty means the first.
Crypt::encode($v, Config::get("crypt/user")) on save, Crypt::decode() on read.
Which Driver Runs
Getting the third wrong sends with the operator's account, not the customer's.
| Path | How the instance is built | Credentials used |
|---|---|---|
| Notifications | Modules::Load("SMS"), then new $smsModule() | saved config |
| International sending | Modules::getInstance("SMS", Config::get("modules/sms-intl")) | saved config |
| Your own module pages | new YourModule($external) | posted values over saved config |
That path is why the constructor takes $external_config; core never passes it. The factory's third argument is a positional argument list, not an options array.
Example
A gateway that covers the home country and hands foreign numbers on.
"Home country" is the country your gateway sells in; read it from config. Strip a leading + before comparing, and mind the operator: != treats '90' and '+90' as equal, !== does not.
<?php
defined('CORE_FOLDER') OR exit('You can not get in here!');
class Acme
{
public $international = false;
public $prevent_transmission_to_intl = false;
public $otp = false;
public $error = null;
public $lang = [];
public $config = [];
private $title = '';
private $body = '';
private $numbers = [];
private $numbers_intl = [];
public function __construct($external_config = [])
{
$this->lang = Modules::Lang('SMS', __CLASS__);
$this->config = array_merge(Modules::Config('SMS', __CLASS__) ?: [], $external_config);
$this->title = (string) ($this->config['origin'] ?? '');
}
public function title($arg = '')
{
$this->title = (string) $arg;
return $this;
}
public function body($text = '', $template = false, $variables = [], $lang = '', $user = 0)
{
// The dispatcher reuses one instance per recipient; without this the buckets grow.
$this->numbers_reset();
if ($template) {
if ($template === "user/gsm-activation" && ($this->config['otp'] ?? false)) $this->otp = true;
$look = View::notifications('sms', $template, $text, $variables, $lang, $user);
if ($look !== false && isset($look['content'])) {
if (isset($look['title'])) $this->title($look['title']);
$text = $look['content'];
}
}
$this->body = (string) $text;
return $this;
}
public function AddNumber($arg = 0, $cc = null)
{
if (!is_array($arg)) $arg = $cc ? [$cc . '|' . $arg] : [$arg];
foreach ($arg as $num) {
if (!str_contains((string) $num, '|')) {
$this->numbers[] = Filter::numbers((string) $num);
continue;
}
// Your gateway's own country, from its config — never a literal.
$home = ltrim((string) ($this->config['home_cc'] ?? ''), '+');
[$ccPart, $numPart] = explode('|', (string) $num, 2);
$ccPart = ltrim($ccPart, '+') ?: $home;
$full = $ccPart . Filter::numbers($numPart);
if (!$this->prevent_transmission_to_intl && $home !== '' && $ccPart !== $home) $this->numbers_intl[] = $full;
else $this->numbers[] = $full;
}
return $this;
}
public function getTitle()
{
return $this->title;
}
public function getBody()
{
return $this->body;
}
public function getNumbers()
{
return array_merge($this->numbers, $this->numbers_intl);
}
public function getError()
{
return $this->error;
}
public function numbers_reset()
{
$this->otp = false;
$this->numbers = [];
$this->numbers_intl = [];
return true;
}
public function submit($isthis = false)
{
if (Validation::isEmpty($this->body)) {
$this->error = 'Message content can not be left blank!';
return false;
}
if (!$this->numbers && !$this->numbers_intl) {
$this->error = 'Enter the phone number to be sent.';
return false;
}
$send = false;
// Foreign numbers go to whichever driver holds modules/sms-intl.
if (!$this->prevent_transmission_to_intl && $this->numbers_intl) {
$intl = (string) Config::get("modules/sms-intl");
if ($intl !== '' && $intl !== 'none') {
$peer = Modules::getInstance('SMS', $intl);
if ($peer) {
$send = $peer->body($this->getBody())->AddNumber($this->numbers_intl)->submit();
// No domestic numbers left, so the peer's outcome is the whole outcome.
if (!$this->numbers) {
$this->error = $peer->getError();
return $isthis ? $this : $send;
}
}
}
}
if ($this->numbers) {
$response = Utility::HttpRequest([
'url' => 'https://api.example.com/sms/send',
'type' => 'POST',
'data' => [
'user' => $this->config['username'] ?? '',
'pass' => Crypt::decode($this->config['password'] ?? '', Config::get("crypt/user")),
'header' => $this->title,
'message' => $this->body,
'to' => implode(',', $this->numbers),
'otp' => $this->otp ? 1 : 0,
],
]);
$decoded = Utility::jdecode((string) $response, true) ?: [];
$send = (string) ($decoded['code'] ?? '') === '00';
// The dispatcher reads the error after a falsy return; it does not catch throws.
if (!$send) $this->error = $decoded['message'] ?? 'Acme refused the batch.';
}
return $isthis ? $this : $send;
}
}
Modules::Load("SMS");
$smsModule = Config::get("modules/sms");
$sms = $smsModule && $smsModule !== 'none' ? new $smsModule() : false;
// $phone arrives as "number|countryCode|lang", so the pieces are unpacked by position.
foreach ($adminContacts['phones'] as $phone) {
$parse = explode("|", (string) $phone);
$aLang = $parse[2] ?? $localLang;
$sendSms = $sms->body($body, $templatePath, $variables, $aLang);
if (isset($parse[1])) $sendSms->addNumber($parse[0], $parse[1]);
else $sendSms->addNumber($parse[0]);
$sendSms = $sendSms->submit();
if ($sendSms) LogManager::Sms_Log(0, $reason, $sms->getTitle(), $sms->getBody(), implode(",", $sms->getNumbers()));
else $errors['sms'][$phone] = $sms->getError();
}
Pitfalls
The joined form is the other way round, countryCode|number, and a stored contact unpacks as number|countryCode|lang. A swap produces a plausible number that fails.
Mail resets recipients inside subject(); SMS resets inside body(). Without it, recipient two gets a batch still holding recipient one.
The client panel prices the batch, refuses unless the driver's $international property is true, then debits the balance. Claiming support only in config.php fails the send.
The notification loop has no try/catch and reads getError() after a falsy return. Settings controllers may throw.
SampleSMS accepts everything and writes each message to temp/sample-sms/.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.