Registering Hooks from a Module
Put a hooks.php in your module directory: that is how a module reaches into the core without editing it.
Overview
Hooks are the whole extension surface. From one file it owns, a module catches an event, changes a value, injects markup, registers a capability or refuses an operation. Nothing in coremio/ knows your module exists.
The catalogue holds 979 hook points in five categories: ui 343, action 298, filter 196, gate 131, register 11. Browse it rather than guessing a name.
Prerequisites
- A module directory at
coremio/modules/{Type}/{Name}/; any of the sixteen types works. - The hook name, copied from
hooks/INDEX.md. A name that does not exist fails silently. - Its page under
hooks/{domain}/: parameters, which are references, and the return contract.
Structure
coremio/modules/*/*/hooks.php: the name and location are fixed.
Walkthrough
Create the File
- Add
hooks.phpat the root of your module directory, beside{Name}.phpandconfig.php. - It is included; write plain statements at the top level.
- Classes under your own
src/are not autoloaded, soinclude_oncethem before you name them.
Gate It on Your Own State
- Load your configuration without instantiating the module: pass
trueas the loader's third argument. - Wrap every listener in a check on your enabled flag, and on your licence when the module is licensed.
- Keep the file cheap; it runs on every request that touches a hook.
Register a Listener
- Call the add method with the hook name, a priority and either a closure or a descriptor array.
- Declare only the parameters you need; the engine matches them positionally.
- To change a value, declare that parameter by reference; only the reference variant carries it back.
Choose a Priority
- Lower runs first. There is no default, so pass a number deliberately.
- Use a low number to shadow a later entry, as route overrides do, and a high one to append last.
Reference
Registering
class Hook
{
// $properties is EITHER a callable OR a descriptor array (see the three forms below).
public static function add($name, $priority, $properties = []): void;
public static function run($name, ...$args): array; // by value
public static function runRefs($name, &...$args): array; // EVERY argument by reference
public static function runDetailed($name, ...$args): array; // per-listener telemetry
}
| Run method | Returns | On a listener exception |
|---|---|---|
run | every non-null listener return, in priority order | logged, then the next listener runs |
runRefs | the same, plus your reference edits reach the caller | logged, then the next listener runs |
runDetailed | [['source' => ['type','class','method','file','line'], 'value' => …, 'error' => ?string], …], nulls kept | captured in error |
| any, unknown name | an empty array | nothing happens; a typo is silent |
The Three Registration Forms
// 1. Closure. Anything callable goes in directly.
Hook::add('action:service.created', 10, function ($id, $data) {
// ...
});
// 2. Instance method. The class is constructed ONCE with NO arguments and cached for the
// whole request, so its constructor must work without parameters.
Hook::add('filter:invoice.totals', 10, [
'class' => 'MyAddonHooks',
'method' => 'adjustTotals',
]);
// 3. Static method. Nothing is constructed.
Hook::add('ui:admin.service_detail.bottom', 10, [
'class' => 'MyAddonHooks',
'method::static' => 'renderPanel',
]);
A missing class or method makes the listener return null and the request continues. That looks exactly like a hook that was never fired. Prefer a closure.
How Arguments Reach Your Listener
// For each parameter your listener declares, at position $i:
// declared by reference AND an argument exists at $i -> passed by reference
// an argument exists at $i -> passed by value
// no argument at $i -> the parameter is dropped,
// so your default applies
$callArgs = [];
foreach ($reflection->getParameters() as $i => $param) {
if ($param->isPassedByReference() && isset($args[$i])) $callArgs[] = &$args[$i];
else if (isset($args[$i])) $callArgs[] = $args[$i];
}
Declaring fewer parameters than the hook supplies is safe. A reference carries your change back only when the hook was fired with the reference variant. On a by-value hook, &$value silently changes nothing.
The Five Categories and What Each Expects Back
| Prefix | Your listener does | Your return value |
|---|---|---|
action: | reacts to an event | ignored |
filter: | changes a value before it is used | the edit goes through the reference parameter |
ui: | injects markup, styles or scripts | the string that gets printed |
register: | registers a capability: cron task, route, menu entry, widget | the registration, or false to register nothing |
gate: | vetoes an operation | a non-empty value blocks, null lets it continue |
The name is category:domain.subject.action, lower case, snake case parts. A ui: name ends in a placement word: its listener gets no context and infers the target from the name.
Reading Your Own Configuration in the Hook File
// $nominc = true loads the config and the language file WITHOUT including the class.
public static function Load($type = '', $name = '', $nominc = false, $status = '');
public static function Config($type, $module);
public static function getInstance(string $type, string $name, array $params = []): ?object;
Modules::Load('Addons', 'MyAddon', true); // config only, no class
$my_config = Modules::Config('Addons', 'MyAddon') ?: [];
if (($my_config['status'] ?? false) && License::valid_addon('my-addon')) {
// ... register listeners here
}
Build the instance inside a listener. Constructing the module on every request only to decide whether to register is the most common reason a module slows the panel.
Example
A hook file touching four surfaces: an event, a value, a page and a scheduled task.
<?php
// src/ classes are not autoloaded: include what this file names, before it names it.
include_once __DIR__ . DS . 'src' . DS . 'Notifier.php';
use WISECP\Modules\Addons\MyAddon\Src\Notifier;
// Config without the class. One cheap call decides whether anything below registers.
Modules::Load('Addons', 'MyAddon', true);
$my_config = Modules::Config('Addons', 'MyAddon') ?: [];
if (!($my_config['status'] ?? false)) return;
/* An event. This hook is fired as ($id, $data): the new users_products id and the row that
was inserted. The return value is ignored, so do the work and say nothing. */
Hook::add('action:service.created', 10, function ($id, $data) {
Notifier::service_created((int) $id, is_array($data) ? $data : []);
});
/* A value. $totals is declared BY REFERENCE because this hook is fired with runRefs;
$invoice and $items are read-only context. */
Hook::add('filter:invoice.totals', 10, function (&$totals, $invoice, $items) {
if ((int) ($invoice['legal'] ?? 0) !== 1) return;
$fee = (float) ($GLOBALS['my_addon_fee'] ?? 0);
if ($fee <= 0) return;
$totals['total'] = round((float) $totals['total'] + $fee, 4);
});
/* Markup. Return the string; the surface prints it. Build the instance HERE, not above. */
Hook::add('ui:admin.service_detail.bottom', 20, fn ($service) =>
Modules::getInstance('Addons', 'MyAddon')->render_service_panel(is_array($service) ? $service : []));
/* A capability. Registration hooks run during bootstrap of the thing they feed. */
Hook::add('register:cronjobs', 1, function () {
include_once __DIR__ . DS . 'cronjobs' . DS . 'SyncTask.php';
CronJobQueue::register(
\WISECP\Modules\Addons\MyAddon\CronJobs\SyncTask::TYPE,
\WISECP\Modules\Addons\MyAddon\CronJobs\SyncTask::class
);
});
The reading side: the core call that fires the value hook above.
// coremio/helpers/Invoices.php, inside recalculate_totals(): after the figures are built
// and before they are written to the row.
Hook::runRefs('filter:invoice.totals', $totals, $invoice, $items);
// $totals is now whatever the listeners left in it, and $persist writes that.
The same care applies when your own module publishes an extension point:
// By value: an event other modules may observe.
Hook::run('action:myaddon.sync_finished', $summary, $startedAt);
// By reference: EVERY argument is by reference here, including the context ones, so
// each of them must be a plain variable first.
$context = ['id' => $recordId, 'lang' => Language::selected()];
Hook::runRefs('filter:myaddon.payload', $payload, $context);
// A gate: a non-empty return from any listener stops the operation.
$veto = Hook::run('gate:myaddon.export', $recordId);
if (array_filter($veto)) throw new Exception((string) current(array_filter($veto)));
Pitfalls
The signature is variadic by reference. A literal, a cast, a function return or a null-coalescing expression in any position is a fatal error. Move each one into a plain variable first; the by-value variant is unaffected.
The engine catches every throwable, logs it and moves on. Your integration does not happen, the page appears normally and nothing says so; a missing include_once is the usual cause. When a listener seems inert, read the error log first.
Hook files are read lazily, on the first run call, and not at all while the installation date is the zero date. Anything needed earlier (a route, a permission catalogue) goes in router.php, loaded while the router is built.
Instantiating your module, querying the database or calling a service at the top level costs that every time, customer page views included. Read the configuration with the class-free loader and build the instance inside the listener.
Registering two listeners at the same number gives the second the next free slot, so order follows registration order, which follows the alphabetical order of module directories. Pick a number far from the crowd.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.