Writing a Hook Listener

4 views Markdown

Writing the function that binds to a hook: taking the parameters in order, giving the return its contract asks for.

Overview

A listener is three things: the name you bind to, the number that is your place in the queue, the function that runs. The hard part is none of those. The hard part is getting the function's signature and return right.

Every hook carries its own contract. One hands you the value by reference and waits for you to change it. One prints the text you return. One throws that text as an error and stops the action. One never looks at your return.

Reading the contract wrong reports nothing: the listener runs and nothing happens.

Prerequisites

  • How Hooks Work read first.
  • The name of the hook you bind to, and its entry. The parameters get their meaning there.
  • A file for the listener: one under coremio/hooks, or your module's hooks.php.

Step by Step

1. Read the contract

  1. A hook's entry holds three lines: Mechanism, Parameters, Return contract. Read all three.
  2. Do not decide from the word in front of the name. On measuring, part of the filter family was called with a copy.
  3. The Ref column decides. Take a marked parameter with & and you can change it.

2. Write the signature

  1. Take the parameters in the order the hook gives them. The names are yours, the order is not.
  2. Leave out trailing parameters you do not need. Fewer is safe, more drops the listener.
  3. Take the parameter you mean to change with &.
signature
// filter:invoice.late_fee_amount — the entry's order: &$fee, $invoice, $cycle
Hook::add('filter:invoice.late_fee_amount', 10, function (&$fee, $invoice) {
    if ((int) ($invoice['user_id'] ?? 0) === 1) $fee = 0.0;   // third parameter left out
});

3. Give the return

  1. Do what the entry's Return contract line says. The spread below helps, but that line decides.
  2. Return null where you mean to touch nothing.
  3. Where you stop an action, return the sentence the user will read.

4. Prove it ran

  1. Put the file in place and do the work that fires the hook. No restart is needed.
  2. Where nothing happened, read the error log. A Hook execution error record says the listener ran and threw.
  3. A Hook listener unresolved record says the class or method name is wrong.
  4. An empty log means the listener was never called: the name is wrong, or the file sits in neither swept place.
  5. For a definite answer, run the hook in diagnosis mode.
diagnosis
foreach (Hook::runDetailed('action:service.created', $id, $data) as $row) {
    echo $row['source']['file'] . ':' . $row['source']['line'] . "\n";
    if ($row['error'] !== null) echo '  FAILED: ' . $row['error'] . "\n";
}

Reference

The spread below was counted from the entries of 984 hooks. It shows what to expect in a family. It is not the truth for any single hook: every row holds a minority that behaves otherwise.

action On 297 hooks the return is ignored. 4 gather telemetry and keep what you return.
filter On 186 hooks you change the value by reference and the return is not read. On 7 you return the changed array; the last non-empty return wins.
gate On 128 hooks a non-empty string stops the action and is thrown as the error. null or an empty string lets the flow carry on.
ui On 329 hooks you return the HTML to print; empty returns are skipped. On 12 you change the object handed to you.
register On 5 hooks you return a registration array. On 4 you widen the array handed to you, on 2 you return a single value.

The running order

A smaller number runs first. The same number cannot be taken twice: a second listener slides to the next free number.

Pick the number against the other listeners. Take a large one to change a value with the last word. Take a small one to stop an action before anyone else.

Example

One installation's rules for corporate customers: no late fee, a word to an outside system when a service opens, no deleting a service while the account owes money.

coremio/hooks/corporate.php
<?php

// A VALUE: zero the late fee by reference.
Hook::add('filter:invoice.late_fee_amount', 10, function (&$fee, $invoice) {
    if (Corporate::has((int) ($invoice['user_id'] ?? 0))) $fee = 0.0;
});

// AN EVENT: a service opened. Our return is not read.
Hook::add('action:service.created', 10, function ($id, $data) {
    if (Corporate::has((int) ($data['owner_id'] ?? 0))) Crm::openedService($id, $data);
});

// PERMISSION: a non-empty string stops the delete; the operator reads this sentence.
Hook::add('gate:service.delete', 10, function ($service) {
    $uid = (int) ($service['owner_id'] ?? 0);
    if (Corporate::has($uid) && Corporate::owes($uid))
        return 'A corporate account with an unpaid invoice keeps its services.';
    return null;
});

All three sit in one file under three different contracts. One changes its parameter, one returns nothing, one returns text and cuts the action short.

Pitfalls

The ampersand is wasted where the hook offers none

Writing &$value is not enough on its own; the value has to have been sent by reference. Where the entry does not mark that parameter, your change stays in a local copy. There is no symptom: no error, only no effect.

A by-reference hook takes no literal

Where you open a hook in its by-reference form, every argument has to be a variable. Passing an array literal, a text literal, a function's return or a ?? expression is a fatal error and takes the page down. Put context values in a variable first.

The class form shares one object

Registering a listener as a class method builds that class once. Every hook using it shares one object. Work in the constructor then happens once per request rather than per hook, and state you keep carries to the other hooks. Use the static form where you do not want that.

With equal numbers the registering order decides

Giving two listeners the same number does not make them equal; the second slides to the next free one. Which slides depends on the order the files load in, and that changes as modules are added. Give listeners whose order matters numbers of their own.

Do not give a gate work to do

Blocking hooks run on every attempt and their only job is to say yes or no. Calling a remote service or writing data inside one slows the flow down. Worse, another listener may already have stopped the action, leaving what you wrote as a record of something that never happened.

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.