Writing an Addon Module

3 views Markdown

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.

An addon module is not a product add-on

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.

AddonModule The base class: configuration, language, encryption, the settings save path, the enable and disable switch. Nothing is abstract or required.
hooks.php A file next to the class, loaded on every request, registering the listeners that put the addon into core flows.
adminArea() An optional page in the admin panel, routed under the addon's own address with no route registration.
clientArea() An optional page in the client area, with a menu entry and an optional pretty address.
use_ methods The request bridge. A method whose name starts with 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

layout
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
config.php, the whole contract
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
];
ReachHowA module that does it
A page in the admin paneladminArea() plus a viewEvery addon with a screen
A page in the client areaclientArea() plus the configuration flagThe sandbox archetype
Markup injected into a core screenA ui: hookThe AI assistant, on the ticket reply editor
A scheduled jobregister:cronjobs plus a queue handler classThe accounting bridge, polling invoice state
Reacting to a core eventAn action: hookThe accounting bridge, on invoice formalization
Its own API endpointsfilter:api.routes plus handler methodsThe live chat widget
An extra admin menu entryregister:admin.menuThe chat and the virus scanner
An extra operation on a core controllerregister:admin.operationsThe 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.

  1. Name the class exactly like the directory. The base derives the name by reflection, so a mismatch breaks every path it builds.
  2. Define fields() to get a settings form, built by the same field engine as the other module types.
  3. 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.

  1. fields() returns the descriptor map, each entry reading its current value from $this->config['settings'].
  2. save_fields() is optional: it receives the posted values, validates them, and returns the array to store. Encrypt secrets here.
  3. 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.

  1. 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.
  2. Register the listeners. Anything that produces markup goes in a closure, so nothing is built for a page that will not show it.
  3. 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.php is the supported shape.

Reference

What the Base Class Provides

exact signatures
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();
}
view($file, $variables) Loads views/ from your module directory with the given variables extracted. Pass the file name including the extension.
save_config(array $data): bool Replaces the whole configuration file: read it, change what you need, write the result. It goes through the managed file writer, which handles the compiled-file cache.
encode_str(), decode_str() Encrypt and decrypt with the installation-bound key named by $cryptKey, the system key by default. Empty stays empty; a failed decryption returns an empty string, not the ciphertext.
isEnabled() Reads the status flag out of the configuration. Every hook listener should consult it before doing anything.
privileges() The full privilege list, for a settings screen where the operator picks which roles may open the addon.
use_default_settings($formElements) Wraps the standard settings screen around your fields: status switch, privilege picker and save button.

Optional Methods

None exists on the base class. Each is probed with method_exists() and skipped when absent, so the signature is the contract.

exact signatures
// 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;
MethodRuns whenReturning false means
fieldsThe settings screen opens, and again on savenot applicable
save_fieldsSettings are saved, before anything is writtenAbort, with the message from the error property
enableThe operator switches the addon onIt stays off
disableThe operator switches it offIt stays on
uninstallThe addon is removedRemoval is refused
adminAreaThe admin opens the addon pagenot applicable
clientAreaA signed-in customer opens the addon pagenot applicable
mainA visitor opens the public pagenot applicable

The Page Descriptor

adminArea() and clientArea() return
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.

name resolution, both sides
$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");
the admin bridge Behind the admin session and the addon privilege; the one an admin-only addon uses.
the website bridge Open only to an addon with a web face. A client page requires a signed-in customer; a public page does not.
no web face, no website bridge Without a client page or a public page the website dispatcher cannot reach the addon at all.
a falsy return is an error Return a non empty array or a non empty string. true, [] and '' are all read as failure and turn into an exception.
no arguments The method takes none. Read the request yourself, through the input filter, exactly as an operation would.

Hook Points Addons Actually Use

register:cronjobs Register queue handler classes. It runs on every installation, because a disabled addon never has a job dispatched to it. Register from inside the listener; the return is ignored.
filter:api.routes Append the addon's own endpoints. Check the audience argument first, then push route tuples pointing at your own methods.
register:admin.menu Add an entry to the admin navigation, so an operator can find the addon.
register:admin.operations Attach an extra operation to a core controller, so the addon can answer a request on a screen it does not own.
action: hooks React to something that happened. This one fires when an invoice is formalized, where an accounting bridge starts.
ui: hooks Inject markup into a core screen. The ticket detail page carries several, and that is where the assistant and the scanner appear.
action:module.addon_settings_saved Fires after any addon's settings are written, with the module name and the new configuration.

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.

the class
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];
    }
}
hooks.php
<?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) : '';
    });
}
reading the settings back
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

The hook file runs on every request, including the ones that ignore you

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.

save_config() replaces the whole file

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.

A client page also opens the website bridge

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.

A bridge method must not return something falsy

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.

The core must never know your module's name

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.

Throw, and do not copy the error property from the sandbox

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.

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.