Writing a Product Module
A product module provisions what is neither a hosting account nor a domain: a licence, a subscription, a certificate. No server record behind it.
Overview
A service that is neither hosting nor server nor domain resolves to the Product type: special and software services, and SSL certificates.
Four of the seven Product modules are SSL products, and SSL has its own base class beneath the generic one.
Prerequisites
- Module Anatomy and Module Configuration.
- Certificates extend the SSL base, everything else the generic one.
- Provider API credentials, read from
$this->config. - A product of type
specialorsoftwarebound to the module.
Structure
The same shape as every module type, minus the server.
coremio/modules/Product/Acme/
├── Acme.php extends ProductModule, or SslProductModule for certificates
├── ApiClient.php your HTTP wrapper
├── config.php metadata and the settings form
├── logo.png
├── lang/en.php
└── pages/
├── configuration.php the module settings screen in the admin panel
└── dashboard.php the management surface; its presence opens the client tab
| Difference | Server module | Product module |
|---|---|---|
| Credentials | Server record, base class decrypts | Module configuration, you decrypt |
| Connection test | test_connect() on the server form | controller_test_connection() |
| Settings screen | From the configuration fields | Your page_configuration() + save controller |
| Resource limits | get_limit(), base class resolves | From the product module data |
| Client tools | Tool catalogue and shared templates | Your dashboard page + callable actions |
| Client tab | Always, with a dashboard | Opt in: ship pages/dashboard.php |
Walkthrough
Pick the Base Class
The SSL base is abstract: seven methods to implement, the certificate surface inherited.
namespace WISECP\Modules\Product;
use Exception;
use ProductModule;
// A licence, a subscription, an application tenant.
class Acme extends ProductModule
{
public function __construct()
{
parent::__construct(); // required: it runs initModule('Product')
}
}
namespace WISECP\Modules\Product;
use SslProductModule;
class AcmeSSL extends SslProductModule
{
// Seven abstract methods must be implemented; the rest of the surface is inherited.
protected function initApi(): void { /* ... */ }
public function fetchRemoteStatus(): array { return []; }
protected function sslProductOptions(): array { return []; }
protected function apiReissue(string $csr, string $dcv_method, string $approver_email): array|bool { return true; }
protected function apiResendValidation(string $domain = ''): array|bool { return true; }
protected function apiRevalidate(string $domain = ''): array|bool { return true; }
protected function apiChangeValidationMethod(string $domain, string $method, string $approver = 'admin'): array|bool { return true; }
}
Settings Screen
The module owns its settings page. Methods prefixed controller_ are reachable from it, hyphens turned into underscores.
page_configuration()builds the screen, usually with the form builder.controller_save()writes the posted fields withsave_config(). Encrypt every secret first.controller_test_connection()proves the credentials.
Product Fields
product_configuration() returns the field descriptors the admin fills per product. They become the product's module data and reach provisioning as $this->options['creation_info'].
Lifecycle
Same shape as a server module: read the choices, call the provider, hand back what to store.
- Build the API client lazily inside the method, not in the constructor.
- Make
create()retry safe: the queue re-runs it, so check for an existing account and persist credentials early.
Client Surface
Nothing is exposed to the customer by default; see Product Module Client Management.
Reference
The Generic Base
class ProductModule
{
public bool $client_area = false; // true only while rendering in the client area
public string $area_link = ''; // client controller link carrying the service id
public array $client_callable_methods = []; // handle_* names the customer may run
public array $client_readonly_methods = []; // the subset served over GET, no CSRF token
public function __construct(); // calls initModule('Product')
public function get_page($page_file = '', $vars = []): string;
public function use_controller($param = '');
public function service_management_page(): string;
public function has_client_management(): bool;
public function client_overview_data(): array;
public function client_quick_actions(int $limit = 8): array;
protected function import_service(array $data): int;
}
controller_{param}, hyphens to underscores. An absent method returns nothing, so an unknown page fails quietly.
pages/ directory, injecting $module; falls back to the shared special product templates.
pages/dashboard.php exists or page_dashboard() is defined; override to false for admin only.
Lifecycle Signatures
None exists on the base and none is abstract; the core probes each with method_exists(), so the signature is the contract.
// Settings screen. Reached through use_controller().
public function page_configuration(): string;
public function controller_save(): array;
public function controller_test_connection(): array;
// Product and service forms. Both save methods take their 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. Note the return type is array|bool here, wider than the server type.
public function create(): array|bool;
public function renew(): array|bool;
public function suspend(): array|bool;
public function unsuspend(): array|bool;
public function cancel(): array|bool;
public function upgrade(): array|bool;
public function change_password(string $password): bool;
// Addons, identical in shape to the server type.
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;
// Live state and the admin dashboard.
public function fetchRemoteStatus(): array;
public function getDashboardData(): array;
// Metered billing.
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;
// Customer actions. The name here is the declared name with handle_ in front.
public function handle_reset_usage(): array;
A server module receives the new product as upgrade(array $new_product = []). Product modules declare upgrade(): array|bool and read the new state from the service.
The SSL Contract
abstract protected function initApi(): void;
abstract public function fetchRemoteStatus(): array;
abstract protected function sslProductOptions(): array;
abstract protected function apiReissue(string $csr, string $dcv_method, string $approver_email): array|bool;
abstract protected function apiResendValidation(string $domain = ''): array|bool;
abstract protected function apiRevalidate(string $domain = ''): array|bool;
abstract protected function apiChangeValidationMethod(string $domain, string $method, string $approver = 'admin'): array|bool;
// Suspension has no meaning for a certificate, so both are answered for you.
public function suspend(): array|bool;
public function unsuspend(): array|bool;
// Product fields: the certificate dropdown plus the included SAN count.
public function product_configuration(array $data = []): array;
public function save_product_configuration(array &$values): void;
// The seven customer actions, each already owner, CSRF and active-service guarded.
public function handle_reissue(): array;
public function handle_resend_validation(): array;
public function handle_revalidate(): array;
public function handle_change_validation_method(): array;
public function handle_add_san(): array;
public function handle_remove_san(): array;
public function handle_download_certificate(): string;
// Helpers around the certificate state.
public function stagedSans(): array;
public function certificateId(): string;
public static function collectExpiringServices(string $module, int $maxDays = 30): array;
download_certificate: streamed over GET, so no token and no POST.
email, http, https, dns. Anything else normalises back to e-mail.
$this->lang at construction; your file wins on a clash.
status, domain, ssl_type, sans, sans_included, sans_addon, sans_max, issued_at, expires_at, validation_method, approver_email, dcv_file, dcv_dns, serial_number, signature_algo, key_size, issuer, crt_code and ca_code.
import_service() Keys
monthly: resolves period, duration and price, priced in the buyer's currency first.
period skips the cycle lookup, amount skips the price lookup.
established is forced true; the product's module data goes to creation_info.
active, the three dates to now.
Example
A licence module: settings save, provisioning call, reading the result back.
public function controller_save(): array
{
$endpoint = Filter::init("POST/api_endpoint", "hclear");
if (!$endpoint) throw new Exception($this->lang['err-endpoint-required']);
$this->save_config([
'api_endpoint' => $endpoint,
// Pass-through: any other filter strips what makes a key strong.
'api_key' => $this->encode_str(Filter::init("POST/api_key", "password")),
'mode' => Filter::init("POST/mode", "letters"),
]);
return ['status' => 'successful'];
}
public function controller_test_connection(): array
{
$this->initApi();
$this->api->call('ping');
return ['status' => 'successful', 'message' => $this->lang['connection-ok']];
}
private function initApi(): void
{
// Lazy: the instance is built in contexts that never reach the network.
if (isset($this->api)) return;
include_once __DIR__ . DS . 'ApiClient.php';
$this->api = new ApiClient(
$this->config['settings']['api_endpoint'] ?? '',
$this->decode_str($this->config['settings']['api_key'] ?? ''),
);
}
public function create(): array|bool
{
// What the admin configured on the product, with the order-time copy preferred.
$module_data = ($this->options['creation_info'] ?? []) ?: ($this->product['module_data'] ?? []);
$plan = $module_data['plan'] ?? 'starter';
$seats = (int) ($module_data['seats'] ?? 1);
// Addons add to the base allowance; requirements are what the buyer typed.
$seats += (int) ($this->addon_params['extra_seats'] ?? 0);
$company = $this->requirement_params['company_name'] ?? '';
$this->initApi();
// Retry safety: the queue re-runs this method after a failure.
$existing = $this->options['config']['id'] ?? '';
if ($existing) return true;
$result = $this->api->call('licences', [
'plan' => $plan,
'seats' => $seats,
'company' => $company,
'email' => $this->user['email'] ?? '',
], 'POST');
return [
'config' => [
'id' => $result['licence_id'] ?? '',
'key' => $this->encode_str($result['licence_key'] ?? ''),
],
'login' => [
'username' => $result['username'] ?? '',
'password' => $this->encode_str($result['password'] ?? ''),
],
];
}
// Every later verb starts from what create() returned. The core merged
// 'config' and 'login' into the service options before this ran.
public function cancel(): array|bool
{
$licenceId = $this->options['config']['id'] ?? '';
if (!$licenceId) return true; // nothing was ever provisioned
$this->initApi();
$this->api->call('licences/' . $licenceId, [], 'DELETE');
return true;
}
// Live provider state for the admin dashboard and the client overview.
public function fetchRemoteStatus(): array
{
$licenceId = $this->options['config']['id'] ?? '';
if (!$licenceId) return [];
$this->initApi();
$remote = $this->api->call('licences/' . $licenceId);
return [
'status' => $remote['state'] ?? 'unknown',
'seats_used' => (int) ($remote['seats_used'] ?? 0),
// Format before returning: the client area prints these values as they are.
'expires_at' => DateManager::format(Config::get("options/date-format"), $remote['expires'] ?? ''),
];
}
Pitfalls
upgrade() has a different signature, and there is no resolved limit helper and no tool catalogue. Start from a Product sandbox archetype.
The instance is built on pages that never call the provider.
No server record does it for you: encrypt before saving, decrypt before use, and read the posted value with the pass-through filter.
Setting an error string and returning false is a leftover. Throw a translated exception; the queue records it and retries.
The client area prints values exactly as given, a raw provider timestamp included.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.