Writing a Hook Listener
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'shooks.php.
Step by Step
1. Read the contract
- A hook's entry holds three lines: Mechanism, Parameters, Return contract. Read all three.
- Do not decide from the word in front of the name. On measuring, part of the
filterfamily was called with a copy. - The Ref column decides. Take a marked parameter with
&and you can change it.
2. Write the signature
- Take the parameters in the order the hook gives them. The names are yours, the order is not.
- Leave out trailing parameters you do not need. Fewer is safe, more drops the listener.
- Take the parameter you mean to change with
&.
// 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
- Do what the entry's Return contract line says. The spread below helps, but that line decides.
- Return
nullwhere you mean to touch nothing. - Where you stop an action, return the sentence the user will read.
4. Prove it ran
- Put the file in place and do the work that fires the hook. No restart is needed.
- Where nothing happened, read the error log. A
Hook execution errorrecord says the listener ran and threw. - A
Hook listener unresolvedrecord says the class or method name is wrong. - An empty log means the listener was never called: the name is wrong, or the file sits in neither swept place.
- For a definite answer, run the hook in diagnosis mode.
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.
null or an empty string lets the flow carry on.
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.
<?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
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.
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.
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.
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.
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.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.