How Hooks Work

5 views Markdown

The way into the system's flow without touching a core file: a name, a running order and a function to run.

Overview

A hook is the point where the core stops and asks "has anyone here got something to say?" An order is saved and it asks. An invoice total is worked out and it asks. A management screen is about to be drawn and it asks. You leave a function behind, and the core calls it on reaching that point.

What this buys you: behaviour added without editing the core. An edited core file comes back on the first update. A hook listener sits in your own file, and the update leaves it alone. This is also how modules bind themselves to the system.

This installation holds 984 hook points spread over seventeen domains by subject. The word in front of the name says what it was opened for.

action Something happened and you are being told. Your return is not read — write to an outside system, keep a record, send word. 301 hooks.
filter A value passes through your hands and you can change it: an amount, a list, a query, template data. 199 hooks.
gate Permission is asked before an action runs and you can stop it by saying no. 131 hooks.
ui You put your own markup at a named place on a screen. The largest family: 342 hooks.
register You make a new capability known to the system: a route, a dashboard piece, a report. 11 hooks.

Prerequisites

  • A file for your listener to live in: a .php file in the installation's own coremio/hooks directory, or a module's hooks.php.
  • A finished installation. No listener loads before the setup wizard is done (see the pitfalls below).
  • The exact name of the hook you are binding to. A wrong name does nothing at all and reports nothing either.

Structure

Listener files are read on their own and you register them nowhere. Two places are swept and both load together.

loading
coremio/hooks/*.php                     # the installation's own listeners
coremio/modules/{Type}/{Name}/hooks.php # every module's own listeners

Loading happens when the first hook runs, not at page start. The files are read once and stay in memory for that request. So adding a listener restarts nothing: you drop the file in and it runs on the next request.

A hook takes as many listeners as you like. They all run by running order, and the smaller number goes first.

Reference

Registering a listener

signature
// The Hook class — coremio/classes/Hook.php
static function add(string $name, int $priority, callable|array $properties): void;
$name The full name of the hook you bind to. It is not checked, and a wrong name binds silently to nothing.
$priority The running order, smaller first. The same number cannot be taken twice: a second listener slides to the next free number, so the order of registering decides.
$properties The thing to run. One of four forms (below).

The four listener forms

forms
// 1 — a closure: the common one
Hook::add('action:order.created', 10, function ($order) {
    Crm::push($order['id']);
});

// 2 — a class method: the class is built ONCE and shared across every hook
Hook::add('filter:invoice.late_fee_amount', 10, ['class' => 'AcmeBilling', 'method' => 'adjust']);

// 3 — a static method: no object is built
Hook::add('gate:order.checkout', 10, ['class' => 'AcmeGuard', 'method::static' => 'allow']);

// 4 — the constructor: with NO method key the class is built and THE OBJECT is the return
Hook::add('register:routes', 10, ['class' => 'AcmeRoutes']);

How the core runs a hook

signature
static function run(string $name, mixed ...$args): array;
static function runRefs(string $name, mixed &...$args): array;
static function runDetailed(string $name, mixed ...$args): array;
Hook::run() Passes a copy of the arguments. A change the listener makes to the variable never reaches the caller, and the returns are gathered into an array.
Hook::runRefs() Passes the arguments by reference: a listener writing &$x in its signature changes the caller's variable. This is how the filter family works.
Hook::runDetailed() Returns each listener's source file, line, return and error. The "who is listening to this" screen in the panel uses it, and it is the right tool for diagnosis too.

What all three share: a listener that throws is caught, recorded, and the next one runs. A single broken listener stops neither the core nor the other listeners.

Example

One file, three listeners, three different jobs: catching an event, changing a value and stopping an action.

coremio/hooks/acme.php
<?php

// 1 — AN EVENT: an order was written, tell the outside. Our return is not read.
Hook::add('action:order.created', 10, function ($order) {
    Crm::push((int) $order['id'], $order);
});

// 2 — A VALUE: change the row. With & on the first argument the caller's variable changes.
Hook::add('filter:admin.table.rows', 10, function (&$row, $table) {
    if ($table === 'services') $row['name'] = strtoupper($row['name'] ?? '');
});

// 3 — PERMISSION: returning a non-empty string stops the action and that text reaches the user.
Hook::add('gate:order.checkout', 10, function ($member, $items) {
    if (Blocklist::has((int) ($member['id'] ?? 0)))
        return 'This account cannot order.';
    return null;                    // null or an empty string: the flow carries on
});

It ends the moment you drop the file in. There is no registering, no build and no restart; all three run on the next request.

Pitfalls

A wrong name says nothing

Binding a listener to a hook name that does not exist reports nothing; the listener never runs. Where a listener "does not work", the first place to look is not the code but the name. Check it against the hook index rather than writing it from memory.

An empty argument is skipped and the rest slide

The core binds a listener's parameters in order and skips an empty (null) argument. The gap does not close: everything after it slides one to the left and the second parameter lands where the first was. Never pass an optional context value as empty; pass nothing, or a placeholder such as an empty string or an empty array.

The returned array does not line up with the listeners

The gathered array carries the non-empty returns alone. Where one of three listeners returns empty, the array holds two items and the first item is no longer the first listener. Taking "the first result" is safe on hooks with a single listener and nowhere else.

One parameter too many drops the listener

Writing a three-parameter listener where the hook throws two values makes the call fail. The failure is caught and recorded, and the listener is skipped. What you see from outside is "it does not work". Check how many values the listener takes against the hook's entry. Writing fewer parameters is safe; writing more is not.

The prefix states intent, not mechanism

A name starting with filter: is no guarantee the value arrives by reference. On measuring, part of the filter family was being called with a copy. Do not read off its name whether a hook can truly change its value. Read it from the mechanism line in the hook's entry.

No listener loads before setup finishes

While the setup stamp is empty, none of the hook files are read. Do not expect to reach into the setup wizard's flow with a hook; that ground comes before hooks exist.

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.