# Registering Hooks from a Module

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

- **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

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

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

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

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

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

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

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

## Related Articles

- [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work)
- [The Hook Catalog](https://dev.wisecp.com/en/hook-domains)
- [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener)
- [Exposing API Endpoints](https://dev.wisecp.com/en/exposing-api-endpoints)
- [Adding a Dashboard Widget](https://dev.wisecp.com/en/adding-a-dashboard-widget)
- [Module Anatomy](https://dev.wisecp.com/en/module-anatomy)
