Hooks in Djebel: Actions, Filters and Wildcard Patterns

Almost everything in Djebel is a plugin, and plugins talk to the framework and to each other through hooks. A plugin fires a hook at a moment that matters, and any other plugin can listen to it without either one knowing about the other.

If you have written WordPress hooks, the idea is the same: actions announce that something happened, filters pass a value through a chain of listeners. Djebel keeps that model and adds a few things of its own, the newest being wildcard patterns - one listener for a whole family of hooks, including hooks nobody has added yet.

All of it lives in one class, Dj_App_Hooks.


Actions: Something Happened

An action is an announcement. The code that did the work fires it with the facts, and listeners react.

$ctx = [
    'email' => $email,
    'message' => $message,
];

Dj_App_Hooks::doAction('app.plugin.contact.message_processed', $ctx);

A listener registers once, usually in its plugin's init():

Dj_App_Hooks::addAction('app.plugin.contact.message_processed', [ $this, 'saveToCsv', ]);

The listener receives the params that were fired, and the formatted hook name as a second argument if it wants it:

public function saveToCsv($ctx = [])
{
    if (empty($ctx['email'])) {
        return;
    }

    // write the row
}

One callback can listen to several hooks at once by passing an array of names:

Dj_App_Hooks::addAction([ 'app.core.init', 'app.plugin.contact.form_start', ], [ $this, 'setup', ]);

Filters: Change a Value

A filter passes a value through every listener and returns what comes out the other end:

$content = Dj_App_Hooks::applyFilter('app.page.content', $content, $ctx);

Each listener receives the current value and the context, and returns the value for the next one:

Dj_App_Hooks::addFilter('app.page.content', [ $this, 'addReadingTime', ]);

public function addReadingTime($content, $ctx = [])
{
    // change only what this listener owns, then hand it on
    return $content;
}

When all you need is a constant answer, there is no need to write a method. The predefined returns cover the common ones:

Dj_App_Hooks::addFilter('app.core.theme.load_theme', Dj_App_Hooks::RETURN_FALSE);

RETURN_TRUE, RETURN_FALSE, RETURN_ZERO, RETURN_NULL, RETURN_EMPTY_STRING and RETURN_EMPTY_ARRAY are available.


Priority: Who Goes First

The third argument is the priority. Lower numbers run first, and the default is Dj_App_Hooks::DEFAULT_PRIORITY, which is 20:

Dj_App_Hooks::addFilter('app.page.content', [ $this, 'runsEarly', ], 5);
Dj_App_Hooks::addFilter('app.page.content', [ $this, 'runsLate', ], 1000);

Listeners at the same priority run in the order they were registered. A priority above 10000 is refused.

Priorities are kept sorted when a listener is registered, not when a hook fires, so firing a hook never pays for sorting.


Hook Names Are Formatted

Before a name is stored or fired, Djebel formats it, and every spelling that formats the same is the same hook:

  • dots, colons and whitespace become slashes, so app.page.content and app/page/content are one hook
  • names are lowercased
  • plugins, themes, pages and apps in the middle of a name are made singular, so app.plugins.seo is app/plugin/seo

Pick one spelling for a project and stay with it. Slashes are what everything is formatted to, and dots read well in PHP code - both are fine.

The formatting is remembered per spelling, so after the first call a name costs one lookup.


Named Callbacks Only

A listener is a class method, a static method or a named function:

Dj_App_Hooks::addAction('app.core.init', [ $this, 'init', ]);
Dj_App_Hooks::addAction('app.core.init', [ 'My_Plugin', 'boot', ]);

Closures are refused at registration. A closure has no name, so it is hard to remove later from anywhere but the line that created it, and hard to find when you are reading a stack trace at two in the morning.


Removing and Parking Listeners

removeAction() and removeFilter() take the same name, callback and priority that were used to add the listener:

$is_filter_removed = Dj_App_Hooks::removeFilter('app.page.content', [ $this, 'runsLate', ], 1000);

Sometimes you want a listener off for a while, not gone. disableAction() and disableFilter() park it, and enableAction() / enableFilter() put it back where it was. Leave the callback out and every listener on that hook is parked:

try {
    Dj_App_Hooks::disableFilter('app.page.content');

    // render something without those filters
} finally {
    Dj_App_Hooks::enableFilter('app.page.content');
}

The finally is the part worth copying. A parked hook stays parked for the rest of the request, so anything that throws in between would leave it off for every later caller too.

Removing and re-adding can do the same job, but then it is on you to put the listener back with the same callback and the same priority. Get the priority wrong and it returns in the wrong place in the order, quietly. A parked entry keeps its priority, so there is nothing to remember.

Parked listeners are moved out of the registry rather than flagged, so a hook firing with its listeners parked pays nothing for them. It also means a parked hook is invisible rather than empty: hasFilter() answers false and applyFilter() hands your value straight back, so nothing downstream needs a special case for it.

All four return true when something actually moved, so a mistyped hook name answers false instead of pretending it parked something.

hasAction() and hasFilter() answer whether anything is registered under a name.


Work After the Response

Some work does not need the visitor waiting for it: sending an email, pinging an API, writing a log.

A deferred action runs on the same hook, but after the response has gone out:

Dj_App_Hooks::addDeferredAction('app.plugin.contact.message_processed', [ $this, 'notifySlack', ]);

The hook fires as usual, the params are captured, and the listener runs during shutdown with the params from each fire. For work that is not tied to any hook, addShutdownAction() registers directly on app/shutdown.

Parking works here too: disabling an action takes its deferred entry with it, so a parked hook does not come back to life after the response. Enabling restores both.


Wildcard Patterns: One Listener, a Whole Family of Hooks

Now the new part. Picture a data layer that fires a hook for every table it saves:

qs_app/vehicles/action/post_save
qs_app/users/action/post_save
qs_app/invoices/action/post_save

A cache that has to be cleared after any save used to need one registration per table, and the table added next month was missed without anyone noticing. With a pattern it is one line:

Dj_App_Hooks::addAction('qs_app/*/action/post_save', [ $this, 'clearCache', ]);

That listener runs for every table, including tables that do not exist yet.

The Syntax

There are two tokens.

* matches any characters inside one segment. In the middle of a pattern, or as part of a segment, it never crosses a slash:

Pattern Matches Does not match
qs_app/*/action/post_save qs_app/vehicles/action/post_save qs_app/vehicles/images/action/post_save
qs_app/vehicle*/action/post_save qs_app/vehicle_images/action/post_save qs_app/users/action/post_save

As a whole first or last segment, * reaches any depth, the same as **:

Pattern Matches
qs_app/* qs_app itself, and anything below it
*/post_save post_save, a/b/post_save

** matches zero or more whole segments, at the start, in the middle or at the end:

Pattern Matches
qs_app/**/post_save qs_app/post_save, qs_app/a/post_save, qs_app/a/b/post_save
**/post_save post_save, a/b/post_save
qs_app/** qs_app itself, and anything below it

Patterns go through the same name formatting as everything else, so qs_app.*.action.post_save is the same pattern, and they work with addFilter() exactly as with addAction(). A slip on the star key costs nothing either: a run of stars, and ** twice in a row, read as a single **.

One Listener for Everything

A lone * or ** matches every hook name:

Dj_App_Hooks::addAction('*', [ $this, 'logEveryAction', ]);

That is handy for logging or debugging, but it runs on every fire, and a catch-all filter joins every value chain on the site. Keep it out of production unless that is exactly what you want.

The One Rule

A pattern cannot be a deferred action. The shutdown replay runs listeners by exact name, so a deferred pattern would never run, and Djebel throws a Dj_App_Hooks_Exception instead of dropping it.

Order

Exact and pattern listeners share one priority order. A pattern listener at priority 10 runs before an exact listener at 20, and after one at 5.

At the same priority, the listeners registered on the exact name run first, then pattern listeners in the order their patterns were registered. A callback that reaches a hook through both an exact name and a pattern at the same priority runs once.

For a catch-all that should see everything the others did, register it at priority 10000 - the highest Djebel accepts. Nothing can be registered after it, and a listener that ties with it on the exact name still goes first:

Dj_App_Hooks::addAction('*', [ $this, 'logEveryAction', ], 10000);

Timing Every Hook

Two catch-all listeners and the ends of the priority range give you a profiler without touching the framework. One at 0 runs before anything else on a hook, one at 10000 runs after:

Dj_App_Hooks::addAction('*', [ $this, 'startTimer', ], 0);
Dj_App_Hooks::addAction('*', [ $this, 'stopTimer', ], 10000);

getCurrentAction() inside the listener tells it which hook is being timed, so one pair covers the whole site. Hooks that nobody listens to are covered too - they still fire, so you also get to see what the site announces and no one uses.

Filters take the same pattern - * and ** work with addFilter() exactly as with addAction() - with one rule: a timing listener returns the value exactly as it was handed to it. On this side the listener asks getCurrentFilter() which chain it is in.

Dj_App_Hooks::addFilter('*', [ $this, 'startFilter', ], 0);
Dj_App_Hooks::addFilter('*', [ $this, 'stopFilter', ], 10000);

The one at 0 sees the value before any filter touched it, the one at 10000 sees what the chain produced - worth having on its own the day a value is not what you expected.

This times each hook's whole chain, not the individual listeners inside it. Keep it in a plugin you switch off in production: it joins every fire and every filter chain on the site.

Seeing What Will Run

Once patterns are involved, grepping for a hook name no longer finds every listener. Ask the registry instead:

$params = [ 'hook_name' => 'qs_app/vehicles/action/post_save', ];
$run_list = Dj_App_Hooks::getActions($params);

That returns the listeners that run for that name, exact and pattern ones together, in run order. getFilters() takes the same key.

What It Costs

A site that registers no patterns pays one emptiness check per fired hook, nothing more. A site that does register patterns only does the matching on the registry that has them: action patterns never slow down filters, and the reverse.


Knowing Where You Are

A listener shared by several hooks can ask which one is running:

if (Dj_App_Hooks::isCurrentFilter('app.page.content')) {
    // only on this hook
}

getCurrentAction() and getCurrentFilter() return the name of the hook that is running, formatted, and the is pair above answers about one name. That is also how a pattern listener learns which of the matching hooks fired.

hasRun() tells you whether a hook was fired during this request at all.


Your Next Step

Pick one thing your plugin does that another plugin might care about, and fire an action there with the facts in $ctx. Then, if you have several hooks that differ only in one segment, replace their per-hook registrations with a single pattern and delete the rest.