Your First Module
Build a working module from an empty directory. The class, its configuration, its language files, and the moment it appears in the admin panel.
Overview
A module is a directory named after the module. It holds a class file of the same name. It also holds the two files every module has: a configuration array and a language folder. Nothing has to be registered anywhere. The module list reads the directory, so a correctly named directory is a module the moment it exists.
This walkthrough builds a currency rate module, because that is the smallest type with a real job: it answers three methods. The same skeleton is what every other type starts from, and each type adds its own required methods on top of it.
Prerequisites
- A development installation you can edit and reload. Do not build against an installation you cannot break.
- Write access to
coremio/modules. - The conventions the codebase holds itself to, because a module is read and reviewed like core code.
Structure
Four paths, and the names are not free: the directory, the class file and the class all carry the same name.
coremio/modules/Currency/AcmeRates/
├── AcmeRates.php # the class, named after the directory
├── config.php # returns an array; the module rewrites it when settings are saved
└── lang/
├── en.php # returns an array; 'name' and 'description' are what the panel shows
└── tr.php
Currency here). It decides which contract the class has to satisfy and where the panel lists it.
WISECP\Modules\{Type}. Core classes are then reached with a leading backslash.
Walkthrough
Create the Directory
- Create
coremio/modules/Currency/AcmeRates/. - Create
lang/inside it. - The panel will not list the module yet: it has no class to load.
Write the Class
- Create
AcmeRates.phpwith the namespace, the class and the three properties every module declares. - Load the configuration and the language pack in the constructor, so the rest of the class can read them.
- Implement the methods the type requires, listed in the contract below.
- Reload the modules screen; the module is listed with the name from its language file.
Add Configuration
- Create
config.phpreturning an array of the settings your module needs, with empty or safe defaults. - Implement
save_config()so it merges the submitted values into the array and writes the file back. - Write the file through the file manager rather than with a raw write. A configuration file is PHP, and a stale compiled copy keeps serving the old values after a save.
- Save a setting in the panel and reload; the new value is the one you read back.
Add Language Files
- Create
lang/en.phpandlang/tr.php, each returning an array. - Give both a
nameand adescription: those two keys are what the module list prints. - Reload the list; the module now shows its own name instead of its directory name.
Reference
What a Currency Module Must Implement
There is no base class to extend. The contract is the set of methods the core actually calls, and each type has its own. For Currency it is three:
// Rate fetch. Called by Money::get_exchange_rates() and by the panel's connection test,
// which checks for it with method_exists first. $to holds the target codes, uppercase.
// Return a map of CODE => rate; anything falsy is treated as a failure.
public function exchange_rates(string $from = '', array $to = []): array|false;
// Settings persistence. The currency settings operation calls it with the submitted
// values for this module only, i.e. $_POST['module_data']['AcmeRates'].
public function save_config(array $data = []): bool;
// The settings markup shown on the currency screen. Called unconditionally on the
// instance, so it must exist even if it returns an empty string.
public function page_settings(): string;
module_data[{ModuleName}][{key}]; that is the shape save_config() receives. A differently named field never reaches the module.
name the list falls back to config['name'] and then to the directory name.
Core Calls a Module Makes
// Modules : the factory and the two loaders. All static.
public static function getInstance(string $type, string $name, array $params = []): ?object;
public static function Config($type, $module); // cached; requires a prior load
public static function Lang($type, $module, $lang = ''); // loads the file itself
public static function Load($type = '', $name = '', $nominc = false, $status = '');
public static function getName(string $type, string $module): string;
public static function save_log($type = '', $module = '', $action = '', $request = '', $response = '', $processed = '');
// Utility : request and JSON helpers.
public static function HttpRequest($url = '', $params = [], $retry = 0); // array first argument, see below
public static function jdecode($string = '', $mode = false); // $mode = true for an associative array
public static function jencode($string = '', $flags = 0): string|false;
public static function array_export($array = [], $options = []); // ['pwith' => true] wraps it as a PHP file
// FileManager : the write that invalidates the compiled copy.
public static function file_write($file, $data = null, $mode = 'w', $flags = 0);
HttpRequest() has two shapes; pass an array as the first argument and the rest is ignored:
urlencode(); nothing escapes it for you.
'GET'. A body is only sent when the method is not GET.
['Authorization: Bearer ' . $key, 'Content-Type: application/json'].
Example
The complete class, then the two files it reads. It declares what every module declares and answers the three methods its type asks for. It reports failure the way the rest of the platform does.
namespace WISECP\Modules\Currency;
class AcmeRates
{
public string $name = "AcmeRates";
public ?array $config = null;
public ?array $lang = null;
public function __construct()
{
$this->config = \Modules::Config("Currency", $this->name);
$this->lang = \Modules::Lang("Currency", $this->name);
}
public function save_config(array $data = []): bool
{
$merged = array_replace_recursive($this->config ?: [], $data);
return (bool) \FileManager::file_write(__DIR__ . DS . "config.php", \Utility::array_export($merged, ['pwith' => true]));
}
public function exchange_rates(string $from = '', array $to = []): array|false
{
$key = (string) ($this->config['apiKey'] ?? '');
if ($key === '')
throw new \Exception($this->lang['error-no-key'] ?? 'API key is not set.');
$response = \Utility::HttpRequest([
'url' => 'https://api.example.com/rates?base=' . urlencode($from),
'type' => 'GET',
'header' => ['Authorization: Bearer ' . $key],
]);
// Every call is recorded, so a failing provider can be diagnosed from the panel.
\Modules::save_log("Currency", $this->name, "exchange", ['from' => $from, 'to' => $to], $response);
$data = \Utility::jdecode((string) $response, true);
if (!isset($data['rates']))
throw new \Exception('Unexpected response from the rate provider.');
$out = [];
foreach ($to as $code) $out[$code] = (float) ($data['rates'][$code] ?? 0);
return $out;
}
public function page_settings(): string
{
$key = htmlspecialchars((string) ($this->config['apiKey'] ?? ''), ENT_QUOTES);
// The field name is the contract: this is exactly what save_config() receives.
return '<div class="row mb-0 align-items-center">'
. '<label class="col-sm-3 col-form-label fw-semibold">API Key</label>'
. '<div class="col-sm-9"><input type="text" class="form-control" '
. 'name="module_data[AcmeRates][apiKey]" value="' . $key . '"></div></div>';
}
}
// config.php : the keys page_settings() prints and save_config() writes back.
return [
'apiKey' => '',
'help-link' => 'https://api.example.com/docs',
];
// lang/en.php : 'name' and 'description' are what the module list shows.
return [
'name' => 'Acme Rates',
'description' => 'Exchange rates from the Acme provider. An API key is required.',
'error-no-key' => 'API key is not set.',
];
The other half: how the platform reaches the module. Never construct one with new, because the factory is what loads the class, fills the configuration and caches the object.
$module = Modules::getInstance("Currency", "AcmeRates");
// Guard on the contract, not on the type: a module is a plain class and may predate a method.
if ($module && method_exists($module, "exchange_rates"))
$rates = $module->exchange_rates("USD", ["EUR", "TRY"]);
// Only the configuration and the language pack, without instantiating anything.
Modules::Load("Currency", "AcmeRates", true);
$config = Modules::Config("Currency", "AcmeRates");
$label = Modules::getName("Currency", "AcmeRates");
Pitfalls
A module signals a problem the same way an operation does: throw, with a message a person can read. The caller catches it and surfaces that message. An $error property set alongside return false is a leftover from the previous major version. You will see it in older ports, but new code does not use it.
Write it through the file manager. A raw write leaves the previously compiled copy in place. The panel then shows the old value after a save that actually succeeded.
Utility::jdecode(...) written in a module file is looked up as WISECP\Modules\Currency\Utility and fails at runtime, not at lint time. Prefix core classes with a backslash or import them.
A class you put under the module's own source folder is ordinary PHP and is constructed with new. It is also not autoloaded, so include it before use. The factory is only for the module types the platform knows about.
Related Articles
Merci pour votre retour !
Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.