Writing an Addon Module
An addon is the module type with no fixed job. It gets a settings form, an admin page, a client page and a hook file. From there it reaches anywhere in the product, without a core edit.
Overview
Every other module type answers a defined question. A server module provisions accounts, a payment module takes money, a registrar registers domains. An addon answers none in particular, which makes it the broadest type and the one most often misused.
The nine modules in coremio/modules/Addons share a base class and nothing else. They are a ticket assistant, a chat widget, identity verification, two accounting bridges, a virus scanner, a translation tool, a licence manager and the sandbox archetype.
The words collide. A product add-on is a billing concept: an extra a customer buys alongside a service, invoiced and activated through the purchase flow. An addon module is a plugin you install. Nothing in this article concerns the first one.
use_ is callable over the panel's own dispatcher; nothing else is.
Prerequisites
- Module Anatomy and Module Configuration; this article covers only the addon specific parts.
- Know what you are extending. A new screen needs the admin page; a changed behaviour needs a hook that already exists.
- Decide the audience early. An administrative addon must not expose a client page, because that also opens its request bridge to unauthenticated callers.
Structure
coremio/modules/Addons/Acme/
├── Acme.php the class: extends AddonModule
├── config.php meta, status, access privileges, saved settings
├── hooks.php optional: listeners registered on every request
├── logo.png
├── lang/en.php $this->lang, including the meta block
├── views/ templates rendered by $this->view()
│ ├── index.php the admin overview
│ └── client.php the client page
└── src/ your own helper classes, built with plain new
return [
'created_at' => 1561714288,
'meta' => [
'name' => 'Acme',
'version' => '1.0',
'author' => 'Your name goes here',
'opening-type' => 'normal',
// Client-area menu icon. 'font' means icon is a class, 'image' means a path or URL.
'icon_type' => 'font',
'icon' => 'bi bi-puzzle',
// Optional pretty address: the page also opens at /{slug}, next to /addon/Acme.
'slug' => 'acme',
],
'show_on_adminArea' => true, // draw the admin page
'show_on_clientArea' => true, // draw the client page and its menu entry
'status' => true, // enabled; the panel switch writes this back
'access_ps' => [], // admin privileges allowed to open it
'settings' => [], // written by the settings form, read by your code
];
| Reach | How | A module that does it |
|---|---|---|
| A page in the admin panel | adminArea() plus a view | Every addon with a screen |
| A page in the client area | clientArea() plus the configuration flag | The sandbox archetype |
| Markup injected into a core screen | A ui: hook | The AI assistant, on the ticket reply editor |
| A scheduled job | register:cronjobs plus a queue handler class | The accounting bridge, polling invoice state |
| Reacting to a core event | An action: hook | The accounting bridge, on invoice formalization |
| Its own API endpoints | filter:api.routes plus handler methods | The live chat widget |
| An extra admin menu entry | register:admin.menu | The chat and the virus scanner |
| An extra operation on a core controller | register:admin.operations | The virus scanner and the accounting bridge |
Walkthrough
Write the Class
The constructor does the work. It derives the name from the class, resolves the directory and the public URL, then loads the configuration and the language file. It also fills $this->admin and $this->user.
- Name the class exactly like the directory. The base derives the name by reflection, so a mismatch breaks every path it builds.
- Define
fields()to get a settings form, built by the same field engine as the other module types. - Define
enable()if installing needs to do anything: create a table, seed a row, check a requirement.
Ship the Settings Form
You build neither the form nor the save path. fields() returns descriptors, the panel draws them, and the base class writes the values back under settings in your configuration file.
fields()returns the descriptor map, each entry reading its current value from$this->config['settings'].save_fields()is optional: it receives the posted values, validates them, and returns the array to store. Encrypt secrets here.settings_notice()is optional: return HTML and it appears as a banner above every field.
Add the Pages
Both page methods return the same descriptor: a title, breadcrumbs and content. Routing is automatic, so there is nothing to register. Sub-pages are driven by a request parameter naming a view file, with a fallback when the file is missing.
Reach Into the Core
This is what makes the type useful, and where the discipline lives. Put listeners in hooks.php and gate them on the addon being enabled. A hook point must never know your module's name.
- Load the configuration cheaply at the top and wrap the listeners in a status check. Registering queue handlers is the deliberate exception and stays outside the check.
- Register the listeners. Anything that produces markup goes in a closure, so nothing is built for a page that will not show it.
- If the point you need does not exist, open it properly rather than editing the core in place. A generic hook plus your condition in
hooks.phpis the supported shape.
Reference
What the Base Class Provides
class AddonModule
{
public string|bool $error = ''; // legacy; new code throws instead
public array $config = []; // config.php, already parsed
public array $lang = []; // lang/{lang}.php for the active language
public string $area_link = ''; // the addon's own page address
public string $_name = ''; // the directory and class name
public array $user = []; // the signed-in customer, when there is one
public array $admin = []; // the signed-in administrator, when there is one
public string $url; // public URL of the module directory
public string $dir; // filesystem path of the module directory
protected string $cryptKey = 'system';
public function __construct();
protected function view($file = '', $variables = []): string;
public function privileges();
public function save_settings($pFields, $accessPs): bool;
public function change_addon_status($arg = '');
public function save_config($data = []): bool;
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();
}
views/ from your module directory with the given variables extracted. Pass the file name including the extension.
$cryptKey, the system key by default. Empty stays empty; a failed decryption returns an empty string, not the ciphertext.
Optional Methods
None exists on the base class. Each is probed with method_exists() and skipped when absent, so the signature is the contract.
// Settings form.
public function fields(): array;
public function save_fields($fields = []): array|bool; // return the array to store, or throw
public function settings_notice(): string; // HTML banner above the fields
public function edit_settings_tab(\WISECP\Components\Tab $tab): void; // add a tab to the settings page
// Install lifecycle. Returning false aborts the state change.
public function enable(): bool;
public function disable(): bool;
public function uninstall(): bool;
// Pages. Each returns a page descriptor.
public function adminArea(): array;
public function clientArea(): array;
public function main(): string; // a public page for visitors who are not signed in
// Request bridge: only a use_-prefixed method is reachable.
public function use_sample_method(): array|string;
| Method | Runs when | Returning false means |
|---|---|---|
| fields | The settings screen opens, and again on save | not applicable |
| save_fields | Settings are saved, before anything is written | Abort, with the message from the error property |
| enable | The operator switches the addon on | It stays off |
| disable | The operator switches it off | It stays on |
| uninstall | The addon is removed | Removal is refused |
| adminArea | The admin opens the addon page | not applicable |
| clientArea | A signed-in customer opens the addon page | not applicable |
| main | A visitor opens the public page | not applicable |
The Page Descriptor
return [
'page_title' => 'Acme',
'breadcrumbs' => [
['link' => $this->area_link, 'title' => 'Acme'],
['link' => '', 'title' => 'Reports'], // an empty link marks the current page
],
// Admin only. Buttons drawn next to the page title.
'page_title_buttons' => [
[
'outerHTML' => '', // bypasses the three keys below when set
'element' => 'button',
'attributes' => ['class' => 'btn btn-primary', 'onclick' => "acmeRefresh();"],
'content' => 'Refresh',
],
],
'content' => $this->view('index.php', $variables),
];
The Request Bridge
Two dispatchers reach an addon: one behind the admin session, one on the public site. Both apply the same rule, the requested name is prefixed with use_ and nothing else is callable.
$method = (string) Filter::init("REQUEST/method", "route");
// Spaces, hyphens and dots all normalise to an underscore, then the prefix is added.
$method = "use_" . str_replace([' ', '-', '.'], '_', $method);
if (!method_exists($instance, $method))
throw new Exception("Module does not have a method named {$method}.");
$result = $instance->$method();
// A falsy return is treated as failure, so never return an empty array on success.
if (!$result) throw new Exception($instance->error ?: "Unknown error");
true, [] and '' are all read as failure and turn into an exception.
Hook Points Addons Actually Use
Example
A minimal but complete addon: the settings, the hook file that makes it do something, the code that reads the settings back. A setting nobody reads is the most common defect in this type.
namespace WISECP\Modules\Addons;
use AddonModule;
use Exception;
use Filter;
class Acme extends AddonModule
{
public string $version = '1.0';
public function fields(): array
{
$settings = $this->config['settings'] ?? [];
return [
'api_key' => [
'name' => $this->lang['api-key'],
'description' => $this->lang['api-key-desc'],
'type' => 'password',
'wrap_width' => 100,
// Show the stored value only as a mask; the real key stays encrypted.
'value' => ($settings['api_key'] ?? '') !== '' ? '********' : '',
],
'notify' => [
'name' => $this->lang['notify'],
'type' => 'switch',
'wrap_width' => 100,
// A switch reads 'checked', not 'value'.
'checked' => (int) ($settings['notify'] ?? 0) === 1,
],
'threshold' => [
'name' => $this->lang['threshold'],
'type' => 'text',
'wrap_width' => 100,
'value' => $settings['threshold'] ?? '100',
// Only shown while the switch above is on.
'parent' => 'notify',
'parentEffect' => 'hide',
],
];
}
public function save_fields($fields = []): array|bool
{
// The mask means "unchanged": keep whatever is already stored.
if (($fields['api_key'] ?? '') === '********')
$fields['api_key'] = $this->config['settings']['api_key'] ?? '';
elseif (($fields['api_key'] ?? '') !== '')
$fields['api_key'] = $this->encode_str($fields['api_key']);
if ((int) ($fields['notify'] ?? 0) === 1 && (int) ($fields['threshold'] ?? 0) <= 0)
throw new Exception($this->lang['err-threshold']);
return $fields;
}
public function enable(): bool
{
// Anything installation needs. Returning false leaves the addon off.
return true;
}
public function adminArea(): array
{
$action = Filter::init("REQUEST/action", "route") ?: 'index';
if (!is_file($this->dir . 'views' . DS . $action . '.php')) $action = 'index';
return [
'page_title' => $this->lang['meta']['name'],
'breadcrumbs' => [['link' => '', 'title' => $this->lang['meta']['name']]],
'content' => $this->view($action . '.php', [
'link' => $this->area_link,
'name' => $this->lang['meta']['name'],
'version' => $this->config['meta']['version'],
]),
];
}
// Reachable as ?operation=use_addon_method&method=refresh
public function use_refresh(): array
{
$id = (int) Filter::init("POST/id", "rnumbers");
if (!$id) throw new Exception($this->lang['err-id-required']);
// Never return an empty array on success: the bridge reads falsy as failure.
return ['status' => 'successful', 'id' => $id];
}
}
<?php
// Cheap load: the config only, without including the class. This file runs on
// every single request, so it must cost almost nothing when the addon is off.
Modules::Load('Addons', 'Acme', true);
$acme_config = Modules::Config('Addons', 'Acme') ?: [];
// Queue handlers register OUTSIDE the status check, on every installation: a job
// already sitting in the queue must still find its handler class after the addon
// is switched off. Being disabled just means no new job of this type is dispatched.
Hook::add('register:cronjobs', 1, function () {
require_once __DIR__ . DS . 'cronjobs' . DS . 'AcmeSync.php';
CronJobQueue::register(AcmeSync::TYPE, AcmeSync::class);
});
if ($acme_config['status'] ?? false) {
// React to a core event. The instance is built inside the listener, not outside,
// so an addon that is never triggered never pays for it.
Hook::add('action:invoice.formalized', 1, function ($invoice, $userId = 0) {
$m = Modules::getInstance('Addons', 'Acme');
if (!$m) return;
$m->queue_invoice((int) ($invoice['id'] ?? 0));
});
// Inject markup into a core screen.
Hook::add('ui:admin.tickets_detail.bottom', 1, function ($ticket) {
$m = Modules::getInstance('Addons', 'Acme');
return $m ? $m->ticket_panel($ticket) : '';
});
}
public function queue_invoice(int $invoiceId): void
{
if (!$invoiceId) return;
$settings = $this->config['settings'] ?? [];
// Never empty(): a stored "0" would read as absent and silently flip the switch on.
if ((int) ($settings['notify'] ?? 0) !== 1) return;
// Decrypt only at the point of use, never into a property.
$apiKey = $this->decode_str($settings['api_key'] ?? '');
if ($apiKey === '') throw new Exception($this->lang['err-not-configured']);
$threshold = (int) ($settings['threshold'] ?? 0);
// ... hand the invoice to the provider
}
Pitfalls
Load the configuration with the lightweight call that skips including the class. Build the instance inside each listener, not once at the top: an addon that constructs an API client at file scope taxes every page.
It does not merge. Read the current configuration, change your keys, pass the complete array back. Passing only what you changed drops the meta block, the status flag and the privilege list.
An addon with only an admin page cannot be reached from the public dispatcher. Adding a client page or a public page changes that for every one of its use_ methods, not only the intended ones.
The dispatcher reads any falsy return as failure and raises an exception. A method with nothing to report still returns a non empty payload, a status key for example.
When the behaviour you need is not reachable, open a generic hook point and put your condition in your own hook file. A core file that names your module, your flag or your table is a change the next upgrade overwrites.
The archetype still shows the old pattern of assigning to the error property and returning false. It is a leftover of the previous generation; production addons throw instead.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.