Your First Module

12 Aufrufe Markdown

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.

layout
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
Module type The directory one level up (Currency here). It decides which contract the class has to satisfy and where the panel lists it.
Module name The directory name. Directory, file and class must match exactly, including case.
Namespace Always WISECP\Modules\{Type}. Core classes are then reached with a leading backslash.

Walkthrough

Create the Directory

  1. Create coremio/modules/Currency/AcmeRates/.
  2. Create lang/ inside it.
  3. The panel will not list the module yet: it has no class to load.

Write the Class

  1. Create AcmeRates.php with the namespace, the class and the three properties every module declares.
  2. Load the configuration and the language pack in the constructor, so the rest of the class can read them.
  3. Implement the methods the type requires, listed in the contract below.
  4. Reload the modules screen; the module is listed with the name from its language file.

Add Configuration

  1. Create config.php returning an array of the settings your module needs, with empty or safe defaults.
  2. Implement save_config() so it merges the submitted values into the array and writes the file back.
  3. 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.
  4. Save a setting in the panel and reload; the new value is the one you read back.

Add Language Files

  1. Create lang/en.php and lang/tr.php, each returning an array.
  2. Give both a name and a description: those two keys are what the module list prints.
  3. 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:

the contract
// 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;
Field names in page_settings() Inputs must be named module_data[{ModuleName}][{key}]; that is the shape save_config() receives. A differently named field never reaches the module.
config['help-link'] Read by the currency screen and printed next to the module's settings as a link to the provider's own page. Leave it out and no link is shown.
lang['name'] · lang['description'] What the module list prints. Without name the list falls back to config['name'] and then to the directory name.

Core Calls a Module Makes

signatures
// 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:

url Full address. Build query strings with urlencode(); nothing escapes it for you.
type Method, defaulting to 'GET'. A body is only sent when the method is not GET.
data The body. An array is sent as form fields; a string is sent as-is, which is how you post JSON.
header List of raw header lines: ['Authorization: Bearer ' . $key, 'Content-Type: application/json'].
timeout · connect_timeout · ssl_verify · allow_ipv6 Defaults are 30s, 10s, verification on and IPv6 off. Leave the last two alone unless the provider forces you.

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.

coremio/modules/Currency/AcmeRates/AcmeRates.php
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 and lang/en.php
// 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.

the calling side
$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

Report failure by throwing

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.

A configuration file is compiled PHP

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.

Inside the module namespace an unqualified core class does not resolve

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.

Your own helper classes are not modules

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.

War das hilfreich?

Vielen Dank für Ihre Rückmeldung!

Brauchen Sie weitere Hilfe?

Unser Support-Team ist rund um die Uhr für Sie da, wenn Sie oben nicht fündig werden.