Writing a Server Module
A server module turns a paid order into a real account on a hosting panel or a cloud provider. The core calls your lifecycle methods by name; what you return is written back onto the service.
Overview
Servers is the largest module type: 50 modules, three of them sandbox archetypes for shared hosting panels, dedicated machines and virtualization.
Your class extends ServerModule, which brings in ModuleBaseTrait. Between them they already hold the server, the service, the product, the buyer, the resolved limits, the addon answers and the tool machinery.
The core never constructs your class: it resolves an instance through the factory and calls the verb by name. A verb you did not implement is skipped, and that is the opt-in mechanism of the whole type.
new is never used for a module.
Prerequisites
- Module Anatomy and Module Configuration first; this article covers only the Servers type.
- A provider account with API access and a server record that passes the connection test.
- A test product bound to that server, otherwise
create()has no plan to read. - Failure is reported by throwing, not by returning
false.
Structure
One directory per module, named exactly like the class inside it.
coremio/modules/Servers/Acme/
├── Acme.php the class: extends ServerModule
├── ApiClient.php your HTTP wrapper, plain new, not a WISECP module
├── config.php metadata, server form fields, supported cards and tools
├── logo.png shown in the module picker
├── lang/en.php $this->lang, one file per language
└── pages/ optional templates rendered with get_page()
namespace WISECP\Modules\Servers;
use Exception;
use Language;
use ServerModule;
class Acme extends ServerModule
{
private ApiClient $api;
// Called by set_server(), which the constructor and set_service() both run,
// once $this->server is filled and its secrets are decoded.
protected function define_server_info(array $server = []): void
{
include_once __DIR__ . DS . 'ApiClient.php';
$this->api = new ApiClient($server);
}
}
| Surface | Entry point in your class | Reached from |
|---|---|---|
| Server settings form | config.php fields, then test_connect() | Admin, Servers, Manage Server |
| Product settings | product_configuration(), save_product_configuration() | Product detail, Module tab |
| Provisioning | create(), suspend(), unsuspend(), cancel() | Order approval, admin actions, the queue |
| Client area dashboard | dashboard_data() | Service detail in the client area |
| Client area tools | tool_data(), tool_action() | The tool sidebar, see the tools article |
| Metered billing | metrics_usage() or metrics_usage_bulk() | The usage collection cron |
| Import | list() | Admin, import accounts from the panel |
Walkthrough
Write the Layers in This Order
Lifecycle first is wasted work. create() reads the buyer's choices out of the service options, and those options exist only once the product form is defined.
define_server_info(), thentest_connect(): add the server and prove the credentials.product_configuration()andsave_product_configuration(), plus the callable that fills the plan dropdown.create(), thensuspend(),unsuspend(),cancel().change_password()andupgrade().list(),dashboard_data(), the single sign on methods, metrics.configure_features()and the tools.
Verify each layer against the live provider before moving on: a first failure at the end has six possible causes.
Connecting to the Provider
Credentials come from the server record. config.php decides which fields the form shows, and the base class hands them to you decoded.
return [
'name' => 'Acme Cloud',
// 'hosting' = domain based accounts, 'server' = VPS or dedicated machines.
'type' => 'hosting',
// A long API token belongs in the access hash field, not in the password field:
// servers.password is a varchar and an encrypted long token overflows it.
'use-access-hash' => true,
'require-access-hash' => true,
'use-test-connection' => true,
'use-port' => true,
'not-secure-port' => 2082,
'secure-port' => 2083,
// Extra fields land in $server['fields']. 'crypt' stores the value encrypted.
'fields' => [
'region' => ['type' => 'text', 'name' => '{lang.region}', 'col_class' => 'col-md-6'],
'sub_token' => ['type' => 'password', 'name' => '{lang.sub_token}', 'crypt' => true],
],
// Which service option identifies the account on the provider side.
'service-relationship' => 'domain',
];
The base class round trips $server['password'] in one place, so it always reaches define_server_info() in plain text. Decrypting it again gives the API a mangled secret.
Product Fields and the Plan Dropdown
product_configuration() returns a field descriptor map. What the admin picks is saved as the product's module data, and it reaches your provisioning code as $this->options['creation_info'].
Make the option value the plan name, not the provider's numeric id, and resolve it to an id inside create().
The Lifecycle Body
Every provisioning verb has the same three beats: read what the buyer chose, call the provider, return what to store. Returning an array is how you persist.
- Read the plan from
creation_info, the limits throughget_limit(), the addon answers fromaddon_params, the order form answers fromrequirement_params. - Call the provider. Let the client throw on an API error, or throw yourself with a translated message.
- Return
['config' => [...]]; the core merges it into the service options and every later verb reads the account back from there.
Reference
Lifecycle Signatures
None is declared abstract on the base class. The core probes each with method_exists() and skips what is absent, so the signature is the contract.
// Setup. define_server_info runs from set_server(), before any other verb.
protected function define_server_info(array $server = []): void;
public function test_connect(): array|bool;
public function configure_features(): void;
// Product and service forms. Both save methods take the values BY REFERENCE.
public function product_configuration(array $data = []): array;
public function save_product_configuration(array &$values): void;
public function service_configuration(): array;
public function save_service_configuration(array &$values): void;
// Provisioning.
public function create(): array|bool;
public function suspend(): bool;
public function unsuspend(): bool;
public function cancel(): bool;
public function renew(): bool;
public function upgrade(array $new_product = []): bool;
public function change_password(string $password): bool;
public function change_limits(array $limits): bool;
public function reset_limits(): array|bool;
// Addons. $addon is one row of the service's addon table.
public function addon_create(array $addon = []): array|bool;
public function addon_suspend(array $addon = []): array|bool;
public function addon_unsuspend(array $addon = []): array|bool;
public function addon_cancel(array $addon = []): array|bool;
public function addon_upgrade(array $addon, array $new_addon): array|bool;
// Client area.
public function dashboard_data(): array;
public function tool_data(string $tool, string $action = 'index', array $params = []): array;
public function tool_action(string $tool, string $action, array $data = []): array;
public function sso_panel_login(): string;
public function sso_root_panel_login(): string;
// Metered billing and import.
public function metrics_usage(): array;
public function metrics_usage_bulk(array $services = []): array;
public function metric_enable(array $metric): void;
public function metric_disable(array $metric): void;
public function list(bool $rCount = false, array $filters = [], array $orders = [], int $start = 0, int $end = -1): array|int;
| Method | Required | Called when | Status afterwards |
|---|---|---|---|
| define_server_info | yes | Every instantiation | none |
| test_connect | yes | Test Connection on the server form | none |
| product_configuration | yes | Product detail, Module tab | none |
| create | yes | Order approved, or Recreate in admin | active |
| suspend | yes | Overdue invoice, or manual suspend | suspended |
| unsuspend | yes | Payment received, or manual unsuspend | active |
| cancel | yes | Cancellation processed | cancelled |
| change_password | yes | Account password changed | unchanged |
| upgrade | yes | Plan change on the same module | unchanged |
| renew | optional | Renewal invoice paid | unchanged |
| change_limits, reset_limits | optional | Provider allows per account limit overrides | unchanged |
| addon_* | optional | An addon on the service changes state | addon only |
| list | optional | Admin opens Import Accounts; implementing it makes that screen appear | none |
| metric_enable, metric_disable | optional | Metered billing with provider side limit overrides | none |
What create() May Return
Return true for a bare success, or an array. Every key is an instruction; the reserved ones are merged into the service options recursively.
config: the account identity, so user, the encrypted password, home_dir, the provider side id.
login. Credentials the client area shows or the single sign on uses.
creation_info, the same bag the product form writes into. Record what was actually provisioned.
'inprocess' or 'waiting' keeps the service out of active state while asynchronous provisioning finishes.
hostname, ip, ftp_info.
The State You Already Have
All of it is filled before your method runs. Do not query for any of it.
fields from your config.
save_options() to persist mid method; returning an array does the same at the end.
disk_limit, bandwidth_limit, email_limit, database_limit, addons_limit, subdomain_limit, ftp_limit, park_limit, max_email_per_hour.
addon-params.
requirement-params.
The Core Side of the Call
public static function run_module(int|array $service, string $action, array $params = []): mixed;
public static function instance_module(int|array $service): ?object;
Services::run_module($id, 'create'); // no arguments
Services::run_module($id, 'change_password', [$password]); // one positional string
Services::run_module($service, 'upgrade', [$newProduct]); // the new product array
Services::run_module($service, 'addon_create', [$addon]); // one addon row
terminate() is missing the core tries cancel(), then cancelled().
Example
A complete provisioning method and the core code that reads the return value back. The keys you return are the keys the core merges.
public function create(): array|bool
{
$domain = $this->options['domain'] ?? '';
if (!$domain) throw new Exception($this->lang['error-domain-required']);
// Re-provision: reuse the identity from the previous run instead of minting a new one.
$username = $this->options['config']['user'] ?? '';
if (!$username) $username = self::username_generator($domain);
$password = ($this->options['config']['password'] ?? '') !== ''
? $this->decode_str($this->options['config']['password'])
: Utility::generate_hash(12);
$creation = $this->options['creation_info'] ?? [];
$parameters = [
'username' => $username,
'password' => $password,
'domain' => $domain,
// Option values carry the plan NAME, so resolve it on this server.
'plan_id' => $this->resolve_plan_id($creation['plan'] ?? ''),
'disk' => $this->get_limit('disk_limit'),
'bandwidth' => $this->get_limit('bandwidth_limit'),
// A switch posts "0" or "1" as a string; empty("0") is true, so cast instead.
'shell' => (int) ($creation['shell_access'] ?? 0) === 1,
];
// Order form answers and addon totals, keyed by the names declared in config.php.
foreach ($this->requirement_params as $key => $value) $parameters[$key] = $value;
foreach ($this->addon_params as $key => $value) $parameters[$key] = $value;
// Retry safety: the queue re-runs this method after a failure.
if (!$this->api->account_exists($username))
$this->api->call('accounts', $parameters, 'POST');
return [
'config' => [
'user' => $username,
'password' => $this->encode_str($password),
'home_dir' => '/home/' . $username,
],
// Not a reserved key, so it is written to the option root.
'ip' => $this->server['ip'] ?? '',
];
}
// Services::apply_module_result, simplified to the part that matters to you.
if (is_array($result)) {
if (isset($result['config']) && is_array($result['config']))
$options['config'] = array_replace_recursive($options['config'] ?? [], $result['config']);
// Anything outside the reserved set lands on the option root.
$reserved = ['config', 'login', 'creation_info', 'options', 'status'];
foreach ($result as $rKey => $rVal)
if (!in_array($rKey, $reserved, true)) $options[$rKey] = $rVal;
}
// ModuleQueue then decides the new service status.
$moduleStatus = is_array($result) ? ($result['status'] ?? null) : null;
$targetStatus = $moduleStatus ?: match ($action) {
'create', 'unsuspend', 'register' => 'active',
'suspend' => 'suspended',
'cancel' => 'cancelled',
default => null,
};
// And this is what every later verb reads:
$username = $this->options['config']['user'] ?? '';
$password = $this->decode_str($this->options['config']['password'] ?? '');
Pitfalls
Assigning to the error property and returning false is a leftover of the previous generation. cPanel throws in 41 places and assigns in none. Throw a translated exception and the caller surfaces the message.
A failure after the account exists is retried from the top, and a provider that rejects duplicates then fails forever. Check for the account first, and persist the credentials with save_options() as soon as you have them.
In a load balanced group the order lands on whichever machine has room, and the same plan carries a different id there. The name is stable: save the name, resolve it at call time.
Approval and switch fields post the strings "0" and "1", and empty("0") is true. Write (int) ($data['key'] ?? 0) === 1 instead.
The base class already did it, in one place. A second decryption corrupts the plain text path, and the failure only shows up against a real provider.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.