Module Lifecycle

3 views Markdown

What happens to a module between arriving on disk and being deleted. When it is loaded, when it is built, where its enabled flag lives, and which of your methods the platform calls.

Overview

There is no install routine. Copying the directory into place is the installation, and the module is listed on the next request. Everything after that is a sequence of small, separately triggered steps. Almost all of the methods involved are optional. The platform checks whether your class has them and moves on when it does not.

Two ideas are easy to confuse. Present means the directory exists. That is enough for it to be listed, to have its hook file executed and to be instantiated by anyone who asks. Enabled means an operator picked it, and where that decision is stored depends entirely on the type. Nothing about being disabled stops your code from being loaded.

Structure

The Stages

StageWhat makes it happenWhat runs in your moduleHow it is undone
On diskThe directory is copied, extracted from an archive, or fetched by the panelNothing. No code is executed by the act of arrivingDeleting the directory
Hooks registeredEvery request, for every module directory. No status is checkedhooks.php from top to bottomOnly by gating the body yourself, or removing the file
LoadedSomething asks the registry for this module or for its whole typeNothing. config.php and the language file are read into a static cacheNothing to undo; the cache lives for one request
InstantiatedThe factory is asked for an objectThe base constructor, then yours if you wrote oneNothing to undo. The object is cached for one request
BoundA caller binds a service, an order or a productYour set_service override, when you have oneBinding something else onto the same object
EnabledAn operator turns it on, or an import is told to activate itactivate() then enable(), both optional, both able to refuseDisabling
DisabledAn operator turns it offdeactivate() then disable(), both optionalEnabling again, which reruns the enable methods
DeletedThe delete action on the module listuninstall(), before any file is touchedRestoring the files. Nothing restores data you dropped

Reference

Where the Enabled Flag Lives

Four types keep a status key in their own configuration file. The rest are selected somewhere else, and two have no flag at all.

TypeWhere the decision is storedWritten by
Addonsstatus in the module's own config.phpThe toggle on the addon list, through the base status method
Productstatus in the module's own config.phpThe settings screen for that module group, writing every module's file in one pass
Fraudstatus in the module's own config.phpThe fraud settings screen
SocialAuthstatus in the module's own config.phpThe social login settings
Mail, SMS, IP, CurrencyOne module name in the platform's module configurationThe settings screen for that group. Only one can be active at a time
PaymentA list of module names in the platform's module configurationThe payment settings screen, plus a separate entry naming the card storage gateway
AuthenticationA list of module names in the platform's module configurationThe security settings
CaptchaThe chosen type in the options configuration, alongside an on and off flagThe security settings
ServersNo flag anywhereA server record naming the module is what puts it in use
RegistrarsNo flag anywhereA top level domain extension pointing at the module is what puts it in use
Storage, Pipe, ImportsChosen at the point of useThe backup destination, the ticket mailbox and the import run respectively

Lifecycle Methods You Can Declare

The first five are pure convention. No base class declares them; each is looked up with method_exists and skipped when absent. A falsy return stops the step it belongs to. The last two are different, and the difference matters. change_addon_status is already implemented on the addon base. testConnection is declared abstract on the social login base, which makes it mandatory there rather than optional.

the optional contract
// Enable path, in this order. activate() runs first, then enable().
// Returning false leaves the module disabled and nothing is written.
public function activate(): bool;
public function enable(): bool;

// Disable path, in this order.
public function deactivate(): bool;
public function disable(): bool;

// Runs before the module directory is removed. Returning false aborts the delete.
// This no-argument boolean form is the ADDON one.
public function uninstall(): bool;

// The Authentication type reuses the name with a different shape: the stored
// enrolment data is passed in, and an array is returned. Only 'error' aborts.
public function uninstall(array $data = []): array;   // ['status' => 'successful']

// The Test button. Two shapes, and the caller decides which one you get:
// social login providers are called with NO argument (abstract on the base),
// registrars are called WITH the merged configuration as the only argument.
public function testConnection(): bool;                  // SocialAuth
public function testConnection($config = []): bool;      // Registrars

// Already implemented on the addon base: it runs the four methods above and then
// writes the new status into config.php. Override only to replace that behaviour.
public function change_addon_status($arg = '');
Two of these names are reused with a different signature

Declaring the no-argument testConnection() on a registrar is the trap. The base passes the merged configuration array as the first argument. Your method needs that value to test the credentials the operator typed, and it threw it away. Every shipped registrar declares $config = []. The same applies to uninstall: the addon form takes nothing, the authentication form takes the stored enrolment array.

The Addon Status Chain

This is the sequence in full. Reading it is the fastest way to see why a failing enable() leaves the module exactly as it was.

what the base status method does
public function change_addon_status($arg = '')
{
    $status = $arg == "enable";
    $apply  = true;

    if ($status && method_exists($this, 'activate'))    $apply = $this->activate();
    if ($status && method_exists($this, 'enable'))      $apply = $this->enable();
    if (!$status && method_exists($this, 'deactivate')) $apply = $this->deactivate();
    if (!$status && method_exists($this, 'disable'))    $apply = $this->disable();

    // The flag is written LAST, and only when the module agreed.
    if ($apply) {
        $config           = $this->config;
        $config["status"] = $status;
        $this->save_config($config);
    }

    return $apply;
}

The operation that calls it, so you can see how a refusal reaches the operator and where the two arguments come from:

the calling operation, abridged
$key    = (string) Filter::init("POST/module", "route");
$status = (int) Filter::init("POST/status", "rnumbers");

$instance = Modules::getInstance("Addons", $key);

if (!method_exists($instance, "change_addon_status"))
    throw new Exception("Module class does not have a method named change_addon_status.");

$status = $status ? "enable" : "disable";

// A thrown Exception travels straight out as the error message. A false return is
// the older path: the caller then reads the legacy error property for a reason.
$result = $instance->change_addon_status($status);
if (!$result) throw new Exception($instance->error ?: "Unknown error");

User::addAction($adata["id"], "alteration", "change-addon-status-" . $status, ['module' => $key]);

Hook::run('action:addon.status_changed', $key, $status);

Lifecycle Hooks

Gates can veto, actions only observe. A gate returning a non-empty string turns that string into the error the operator sees.

gate:module.activate Runs before any module group activation is written, with the group and the list of newly activated names. Return a non-empty string, or an array carrying a message, to refuse.
action:module.activated After the group settings were saved, with the names that went from off to on. Only the difference is reported, not the whole selection. The return value is ignored.
action:module.deactivated The mirror of the previous one, with the names that went from on to off. The return value is ignored.
action:addon.status_changed After an addon was enabled or disabled, with the module key and the literal word that was applied. The return value is ignored.
gate:module.addon_install Before an uploaded archive is unpacked, with the upload entry and whether it is to be activated immediately.
action:addon.installed After extraction, with the module key and the activation flag. This is where a marketplace record or an update manifest is written.
gate:module.addon_delete Before an addon is deleted, with its key. Use it to refuse while the module still owns live data.
action:addon.deleted After the directory is gone. The module's own class no longer exists at this point, so listen from somewhere else.
gate:module.delete The same veto for a non-addon module, with the type and the key.
action:module.config_saved After a module's configuration file was rewritten, with the type and the key. Useful for clearing a cache your module keeps.

Example

An enable that creates its own schema, a disable that deliberately keeps the data, and an uninstall that finally removes it. The whole point of the three is that only the last one is destructive.

the module side
public function enable(): bool
{
    // Idempotent on purpose: enable() runs again on every re-enable and after an update.
    $this->check_database();

    return true;
}

/** Only ever adds. Never drops, never rewrites an existing column. */
private function check_database(): void
{
    if (!\WDB::hasTable("Acme_events"))
        \WDB::exec('CREATE TABLE `Acme_events` ('
            . '`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,'
            . '`service_id` INT(11) UNSIGNED NOT NULL DEFAULT 0,'
            . '`payload` TEXT NULL DEFAULT NULL,'
            . 'PRIMARY KEY (`id`)'
            . ') ENGINE = InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci;');

    // Columns added after the table first shipped are checked one by one.
    $col = \WDB::query("SHOW COLUMNS FROM `Acme_events` LIKE 'created_at'");
    if (!($col ? \WDB::getAssoc($col) : false))
        \WDB::exec("ALTER TABLE `Acme_events` ADD `created_at` INT(11) UNSIGNED NOT NULL DEFAULT '0'");
}

public function disable(): bool
{
    // Nothing is dropped here. Disabling is reversible, and an operator who turns the
    // module off for an afternoon must not lose a year of rows.
    return true;
}

public function uninstall(): bool
{
    // Refuse rather than destroy silently when there is still something to lose.
    // select() returns the BUILDER, so build() and getAssoc() are called on it.
    // Handing the builder to WDB::getAssoc() as an argument is a fatal: that
    // parameter expects a PDOStatement, which is what WDB::query() gives back.
    $stmt = \WDB::select('COUNT(id) AS total')->from('Acme_events');
    $rows = $stmt->build() ? (int) (($stmt->getAssoc() ?: [])['total'] ?? 0) : 0;

    if ($rows > 0 && !(int) \Filter::init("POST/purge", "rnumbers"))
        throw new \Exception($this->lang['error-uninstall-has-data'] ?? 'The module still holds records.');

    \WDB::exec("DROP TABLE IF EXISTS `Acme_events`");

    return true;
}

The other side lives in a listener, not in the module. A delete listener has to outlive the class it reacts to:

hooks.php
// Runs on EVERY request, for this module, enabled or not. Keep it to registrations.
Hook::add('gate:module.addon_delete', 1, function ($key) {
    if ($key !== 'Acme') return '';

    $stmt = WDB::select('COUNT(id) AS cnt')->from('Acme_events');
    $stmt->where('processed', '=', 0);

    $open = $stmt->build() ? (int) (($stmt->getAssoc() ?: [])['cnt'] ?? 0) : 0;

    // A non-empty string is the refusal, and it is what the operator reads.
    return $open > 0 ? 'Acme still has ' . $open . ' unprocessed events.' : '';
});

Hook::add('action:addon.status_changed', 1, function ($key, $status) {
    if ($key !== 'Acme') return;

    Cache::getInstance()->clear(['acme']);
});

Pitfalls

Enable runs more than once, so it has to be idempotent

It runs on the first activation, on every re-activation, and it is the usual place an update rebuilds a schema. Check before you create, add columns one at a time, and never let it overwrite a row an operator edited. Seeding is safe only behind a count check on an empty table.

Disabling is not uninstalling, and deleting is not either

Disable must leave every table and every row alone; it is a switch, not a cleanup. Delete removes the directory and nothing else, so anything your module wrote to the database survives it. If the data should go, drop it from the uninstall method, which is the only step that runs before the files disappear.

A disabled module still registers its hooks

Hook files are collected by walking the modules directory, with no reference to any status. Your listeners fire while the module is off unless the body checks the module's own flag first. Treat the hook file as a registration list and put the decision inside each listener.

Refuse by throwing, with a sentence a person can act on

A thrown exception becomes the message on screen. Returning false without a message produces the words "Unknown error". The caller then falls back to a legacy error property that new code does not set, and the operator learns nothing.

Only the addon delete action asks your class first

The uninstall method is invoked by the addon delete action, and by the authentication type when an enrolment is removed. Deleting a module of any other type removes the files after its gate hook has had a chance to refuse. No method on your class is called at all. Put anything that must run at removal time behind the gate rather than in a method nobody invokes.

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.