Registering Hooks from a Module

3 views Markdown

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

classes/Hook.php The engine: registration, priority ordering, argument binding, and the loader that finds your file.
{module}/hooks.php Your listeners. Found by a glob over coremio/modules/*/*/hooks.php: the name and location are fixed.
coremio/hooks/ The core's own listener files, loaded in the same pass, before the module files.
hooks/INDEX.md The generated catalogue: every hook point, its domain, and the file and line that fires it.
{module}/router.php A second, earlier entry point: loaded while the router is built, before hook files.

Walkthrough

Create the File

  1. Add hooks.php at the root of your module directory, beside {Name}.php and config.php.
  2. It is included; write plain statements at the top level.
  3. Classes under your own src/ are not autoloaded, so include_once them before you name them.

Gate It on Your Own State

  1. Load your configuration without instantiating the module: pass true as the loader's third argument.
  2. Wrap every listener in a check on your enabled flag, and on your licence when the module is licensed.
  3. Keep the file cheap; it runs on every request that touches a hook.

Register a Listener

  1. Call the add method with the hook name, a priority and either a closure or a descriptor array.
  2. Declare only the parameters you need; the engine matches them positionally.
  3. To change a value, declare that parameter by reference; only the reference variant carries it back.

Choose a Priority

  1. Lower runs first. There is no default, so pass a number deliberately.
  2. Use a low number to shadow a later entry, as route overrides do, and a high one to append last.

Reference

Registering

exact signatures
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 methodReturnsOn a listener exception
runevery non-null listener return, in priority orderlogged, then the next listener runs
runRefsthe same, plus your reference edits reach the callerlogged, then the next listener runs
runDetailed[['source' => ['type','class','method','file','line'], 'value' => …, 'error' => ?string], …], nulls keptcaptured in error
any, unknown namean empty arraynothing happens; a typo is silent

The Three Registration Forms

the three 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

the binding rule
// 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

PrefixYour listener doesYour return value
action:reacts to an eventignored
filter:changes a value before it is usedthe edit goes through the reference parameter
ui:injects markup, styles or scriptsthe string that gets printed
register:registers a capability: cron task, route, menu entry, widgetthe registration, or false to register nothing
gate:vetoes an operationa 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

exact signatures
// $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;
the guard
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.

MyAddon/hooks.php
<?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.

how the core fires it
// 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:

firing a hook of your own
// 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

Every argument of the reference variant is a reference, context included

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.

An exception inside a listener is swallowed

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.

Work that must run before the first hook does not belong here

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.

The file body runs on every request that touches a hook

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.

A priority collision is resolved, not reported

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.

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.