Module Anatomy

3 views Markdown

The files a module is made of, the three names that must agree, and what the base class hands you.

Overview

A module is a directory with one mandatory class file, plus optional files the platform looks up by name. Nothing is registered: the loader builds each path from the type and the module name. A file in the right place is found, and a misspelled one is ignored without a warning.

Most types give you a base class that has already done the setup. Paths, configuration and language are ready before your first method runs, and provisioning types also know which service they act on. Rebuilding any of it by hand is the most common beginner mistake.

Structure

The File Tree

a full module, required and optional
coremio/modules/{Type}/{Name}/
├── {Name}.php          # REQUIRED: the class, same name as the directory
├── config.php          # returns an array; settings, metadata, field definitions
├── logo.png            # or .svg/.webp/.jpg; resolved by name when config does not name one
├── hooks.php           # Hook::add() registrations, included on EVERY request
├── AdminArea.php       # own admin page, registered from router.php
├── router.php          # include + ModuleAdminArea::register()
├── lang/
│   ├── en.php          # REQUIRED in practice: the fallback language
│   └── tr.php          # one file per translated language
├── pages/              # settings and management markup, resolved by page name
├── views/              # same role, alternative directory name
├── controllers/        # named entry points reached through the controller dispatcher
├── assets/
│   ├── style/          # css
│   ├── js/             # javascript
│   └── images/         # interface images, not the logo
└── src/                # your own helper classes, NOT autoloaded

What Each File Is For

PathRequiredRead whenWhat it holds
{Name}.phpYesAn instance is builtThe class. Included unless the loader is told to skip it.
config.phpIn practice yesEvery load, with or without the classAn array: settings, metadata, field definitions, enabled flag.
lang/en.phpYesEvery loadAn array. The fallback whenever the active language has no file.
lang/{code}.phpOptionalThat language is activeThe same keys, translated.
logo.png and friendsOptionalThe panel shows the moduleThe icon. Found by extension when configuration names no file.
hooks.phpOptionalEvery request, for every module on diskHook registrations. They run whether or not the module is enabled.
AdminArea.php plus router.phpOptionalEvery request, before routingAn admin page of your own: route, menu entry, privileges.
pages/ or views/OptionalA page is shownMarkup addressed by page name; both directory names are tried.
controllers/OptionalA named controller is dispatchedOne file per entry point, tried before the method of that name.
assets/OptionalThe browser requests itStyle, script and image files, addressed through the URL property.
src/OptionalYou include it yourselfYour API client and helpers. The autoloader does not reach here.

Reference

The Three Names That Must Agree

Directory name The module name, and the only thing the loader is given. Case matters on a case-sensitive filesystem: cPanel is not CPanel.
Class file name The directory name plus .php. The loader builds this path and looks nowhere else.
Class name Three candidates are tried in order. The name with a _Module suffix, the bare name in the global namespace, then the name under the type namespace. Write new modules as the third form.
Namespace WISECP\Modules\{Type}, with the type spelled as the directory is. Inside it, a bare core class name resolves into the module namespace and fails at runtime. Import core classes, or prefix them with a backslash.

Base Class by Type

Eight of the sixteen types have one. The rest inherit nothing; their contract is whatever methods the core looks for.

Base classTypeShares the traitWhat its constructor already did
ServerModuleServersYesPaths, configuration, language, the current admin, the default tool set, and the server when one was passed in.
PaymentGatewayModulePaymentNoPaths, configuration, language, the pay button label, an empty client info object.
RegistrarModuleRegistrarsYesPaths, configuration, language, and the crypt sub-key moved from the per-user key to the system key.
ProductModuleProductYesPaths, configuration and language, nothing else.
SslProductModuleProductInheritedAbstract. The product base, plus the validation, reissue and SAN contract every certificate module answers.
AddonModuleAddonsNoPaths, configuration, language, the admin area link, the signed-in admin and member records.
FraudModuleFraudNoPaths, configuration, language, the current controller link, the signed-in identities.
StorageModuleStorageNoAbstract. Takes the storage configuration as a constructor array; no directory or URL property.
SocialAuthProviderSocialAuthYesAbstract. Paths, configuration and language; every endpoint and the token check stay abstract.

Inherited Properties

From the shared trait, so identical in server, registrar, product and social login modules.

PropertyTypeFilled byHolds
$_namestringConstructorModule name; the class short name when you do not set it.
$_typestringConstructorType directory, as the base class declared it.
$dirstringConstructorAbsolute path to the module directory, trailing separator included.
$urlstringConstructorPublic URL of the module directory, trailing slash. Build asset links from it.
$configarrayConstructorThe whole config.php array, already loaded.
$langarrayConstructorModule strings for the active language.
$servicearrayBinding a serviceThe full service record; empty until you bind one.
$productarrayBinding a service or a productThe product the service was ordered from.
$orderarrayBinding an orderThe order record, while provisioning from an order.
$userarrayBinding a serviceOwner identity, contact details and billing address.
$adminarrayBinding a serviceThe admin acting; empty outside the panel.
$optionsarrayBinding a serviceThe service options, where a module keeps its per-service state.
$addonsarrayBinding a serviceThe service add-on records, keyed by add-on id.
$addon_paramsarrayBinding a serviceConfigurable add-on values merged into totals; cancelled and waiting excluded.
$addon_params_by_idarrayBinding a serviceThe same values per add-on, cancelled ones included.
$requirement_paramsarrayBinding a serviceCustomer answers to product requirements, keyed by your parameter name.
$callable_methodsarrayYou declare itAllowlist of methods reachable by bare name from a URL; anything else is not.
$errorstringNothing, in new codeLeft from the previous major version. Report failures by throwing instead.

Inherited Helpers

signatures, from the shared trait
// Context binding. Each takes an id OR the already-loaded record; an int is fetched for you.
public function set_service(array|int $service = []): void;
public function set_product(array|int $product = []): void;
public function set_order(array|int $order = []): void;

// Persist $this->options back onto the bound service. False when nothing is bound.
public function save_options(): bool;

// Rewrite config.php. $auto_status = true flips status on when a settings array is present.
protected function save_config($data = [], $auto_status = true);

// One log row per provider call, shown in the panel's action history.
protected function save_log($action = '', $request = '', $response = '', $processed = ''): int|bool;

// Encrypt with the module's crypt sub-key; $key overrides it for one call.
protected function encode_str(string $str = '', string $key = ''): string;
protected function decode_str(string $str = '', string $key = ''): string;

// Resolved logo URL, or an empty string.
public function logo(): string;

// Metered metric limits enabled on the bound service, keyed by metric type.
protected function enabled_metrics(): array;
protected function enabled_metric_values(): array;
protected function reapply_enabled_metrics(): void;

// Data attributes for a dropdown that loads its options from one of your methods.
protected function method_url_data(string $method): array;

// Dispatch "reset-password" to handle_reset_password(). Returns null when it does not exist.
protected function use_method($param = '');

// Add-on values for one option, multiplied by quantity unless the param opts out.
public function resolveAddonConfigurable(array $moduleParams, int $quantity = 0): array;
save_config() Writes the whole array, so merge into the existing configuration first. Pass false as the second argument when a settings write should not also enable the module.
encode_str() The sub-key is a property, not an argument: user by default, switched to system by the registrar base. A value encrypted under one key does not decrypt under the other.
use_method() Maps a dashed action name onto a handle_ method, dashes becoming underscores. Any other name is unreachable from the panel action buttons.
save_log() Arrays are encoded for you. Put the request URL under api_url in the request array; it is lifted into its own column.

The Addon Exception

An addon module does not share the trait. None of the properties above exist on it, apart from the four its own base declares.

the addon base, in full
// Properties: $config, $lang, $dir, $url, $area_link, $_name, $user, $admin, $error.
public function __construct();

// Render views/{file}.php with $variables extracted into it. Returns the markup.
protected function view($file = '', $variables = []): string;

// Privilege keys this addon adds to the admin role screen.
public function privileges();

// Called by the addon settings screen: $pFields is the posted settings,
// $accessPs the posted privilege selection.
public function save_settings($pFields, $accessPs): bool;

// Enable or disable from the addon list.
public function change_addon_status($arg = '');

// Rewrite config.php. Note the single argument: no auto-status flag here.
public function save_config($data = []): bool;

// Same crypt helpers, but the addon base defaults the sub-key to 'system'.
protected function encode_str(string $str = '', string $key = ''): string;
protected function decode_str(string $str = '', string $key = ''): string;

public function use_default_settings($formElements = null);
public function isEnabled();

Example

A skeleton that declares nothing it inherits, and the caller that drives it.

coremio/modules/Registrars/AcmeDomains/AcmeDomains.php
namespace WISECP\Modules\Registrars;

use Exception;
use Language;
use RegistrarModule;
use WISECP\Modules\Registrars\AcmeDomains\ApiClient;

class AcmeDomains extends RegistrarModule
{
    private ?ApiClient $api = null;

    // No constructor. The base one already set $_name, $_type, $dir, $url, $config and $lang.
    // Build the client lazily instead: the service is bound AFTER construction, so anything
    // that needs $this->service cannot run here.
    private function api(): ApiClient
    {
        if ($this->api) return $this->api;

        // src/ is not autoloaded, so the file is included explicitly.
        include_once $this->dir . 'src' . DS . 'ApiClient.php';

        $settings = $this->config['settings'] ?? [];

        $this->api = new ApiClient(
            (string) ($settings['username'] ?? ''),
            $this->decode_str((string) ($settings['apiKey'] ?? '')),
        );

        return $this->api;
    }

    // NOT create(). The base class owns create() and dispatches to register() or
    // transfer() depending on whether an EPP code is present. Overriding create()
    // would drop the transfer branch and both of its hooks.
    public function register(): array|bool
    {
        // The bound record carries the ordered domain in options; service['name']
        // is the fallback. There is no 'domain' column on the service itself.
        $domain = (string) ($this->options['domain'] ?? $this->service['name'] ?? '');
        if ($domain === '') throw new Exception(Language::gc("acme/error-no-domain"));

        // 'year' is the registration length. service['period'] is the billing cycle
        // string ('y', 'm', 'none'), never a number of years.
        $year = (int) ($this->options['year'] ?? $this->service['period_time'] ?? 1) ?: 1;

        $response = $this->api()->register($domain, $year);

        // Every provider call is recorded; api_url is lifted into its own column.
        $this->save_log('register', ['api_url' => $this->api()->last_url, 'domain' => $domain], $response);

        if (!($response['ok'] ?? false))
            throw new Exception($response['message'] ?? Language::gc("acme/error-refused"));

        return true;
    }
}
the side that drives it
$module = Modules::getInstance("Registrars", "AcmeDomains");
if (!$module) throw new Exception(Language::gc("modules/error-not-found"));

// Binding is a separate step, and it is what fills $service, $product, $user and $options.
$module->set_service($serviceId);

try {
    // The caller always asks for create(). On a registrar that is the base method,
    // which routes to register() or transfer() and runs the domain hooks around it.
    $result = $module->create();
}
catch (\Throwable $e) {
    // A module reports failure by throwing; the caller turns the message into a response.
    return $operation->output(['status' => "error", 'message' => $e->getMessage()]);
}

// Module state the module changed goes back to the service through its own helper.
$module->save_options();

Pitfalls

The hook file of every module on disk runs on every request

A disabled or half-finished module still registers its hooks. Gate the body on the module's own enabled flag, and keep expensive work out of the file.

Your own helper classes are not autoloaded

The autoloader maps the type namespace to the type directory and stops there. An import under the module's source folder loads no file. Include it before first use. A fatal inside a hook silently kills the rest of that hook body.

An unqualified core class name resolves into your namespace

It is looked up under the module namespace and not found, at runtime rather than at lint time. The file appears to work, because the classes you did import are fine.

Do not build an API client in the constructor

Configuration and language are ready there, but the service, product, owner and options are bound afterwards. A client built from service data at that moment reads empty values. It then fails against the provider with a message that points nowhere near the cause. Build it lazily on first use.

Some base methods are orchestrators, not empty slots

A name the core calls is not automatically the name you implement. On a registrar the base already defines create(). It chooses between registration and transfer, runs the gate and action hooks, and turns a submitted transfer into a pending state. You write register() and transfer(). Check the base before overriding.

Two directory names mean the same thing

The names configuration and settings are also tried for each other. Pick one of each and stay with it.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.