Module Lifecycle
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
| Stage | What makes it happen | What runs in your module | How it is undone |
|---|---|---|---|
| On disk | The directory is copied, extracted from an archive, or fetched by the panel | Nothing. No code is executed by the act of arriving | Deleting the directory |
| Hooks registered | Every request, for every module directory. No status is checked | hooks.php from top to bottom | Only by gating the body yourself, or removing the file |
| Loaded | Something asks the registry for this module or for its whole type | Nothing. config.php and the language file are read into a static cache | Nothing to undo; the cache lives for one request |
| Instantiated | The factory is asked for an object | The base constructor, then yours if you wrote one | Nothing to undo. The object is cached for one request |
| Bound | A caller binds a service, an order or a product | Your set_service override, when you have one | Binding something else onto the same object |
| Enabled | An operator turns it on, or an import is told to activate it | activate() then enable(), both optional, both able to refuse | Disabling |
| Disabled | An operator turns it off | deactivate() then disable(), both optional | Enabling again, which reruns the enable methods |
| Deleted | The delete action on the module list | uninstall(), before any file is touched | Restoring 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.
| Type | Where the decision is stored | Written by |
|---|---|---|
| Addons | status in the module's own config.php | The toggle on the addon list, through the base status method |
| Product | status in the module's own config.php | The settings screen for that module group, writing every module's file in one pass |
| Fraud | status in the module's own config.php | The fraud settings screen |
| SocialAuth | status in the module's own config.php | The social login settings |
| Mail, SMS, IP, Currency | One module name in the platform's module configuration | The settings screen for that group. Only one can be active at a time |
| Payment | A list of module names in the platform's module configuration | The payment settings screen, plus a separate entry naming the card storage gateway |
| Authentication | A list of module names in the platform's module configuration | The security settings |
| Captcha | The chosen type in the options configuration, alongside an on and off flag | The security settings |
| Servers | No flag anywhere | A server record naming the module is what puts it in use |
| Registrars | No flag anywhere | A top level domain extension pointing at the module is what puts it in use |
| Storage, Pipe, Imports | Chosen at the point of use | The 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.
// 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 = '');
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.
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:
$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.
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.
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:
// 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
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.
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.
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.
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.
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.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.