# Writing an Addon Module

https://dev.wisecp.com/es/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.

> **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](https://dev.wisecp.com/en/module-anatomy) and [Module Configuration](https://dev.wisecp.com/en/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

```bash
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
```

```php
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`.

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

```php
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/{$file}` 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.

```php
// 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

```php
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.

```php
$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.

```php
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
<?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) : '';
    });
}
```

```php
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.

## Related Articles

- [Module Anatomy](https://dev.wisecp.com/en/module-anatomy)
- [Adding an Admin Page](https://dev.wisecp.com/en/adding-an-admin-page)
- [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module)
- [Exposing API Endpoints](https://dev.wisecp.com/en/exposing-api-endpoints)
- [Adding a Scheduled Task](https://dev.wisecp.com/en/adding-a-scheduled-task)
- [Working Without Touching the Core](https://dev.wisecp.com/en/working-without-touching-the-core)
