Help us improve
Share bugs, ideas, or general feedback.
From moodle-dev
Implements or migrates to the Moodle 4.4+ Hooks API: authoring hook classes, registering callbacks in db/hooks.php, dispatching, replacing legacy magic callbacks, and testing hook listeners.
npx claudepluginhub saadrahman01/claude-moodle-dev --plugin moodle-devHow this skill is triggered — by the user, by Claude, or both
Slash command
/moodle-dev:moodle-hooks-apiThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Moodle 4.4 introduced a typed Hooks API (`core\hook\manager`) replacing the unmaintainable jungle of magic callback functions like `<plugin>_extend_navigation`, `<plugin>_before_http_headers`, `<plugin>_extend_settings_navigation`, etc. Hooks are real classes with typed payloads, dispatched through `\core\di::get(\core\hook\manager::class)`. Plugins register interest via `db/hooks.php`.
Guides Moodle plugin development: version.php, DB install/upgrade, capabilities, web services, PSR-4 autoloading, hooks, settings, privacy provider, and coding standards.
Provides patterns for Drupal 11 OOP hooks with #[Hook] attributes, form alters, entity hooks, and legacy bridges for Drupal 10/11. Use when implementing hooks, form alterations, or event subscribers.
Guides creation of Claude Code plugin hooks with prompt-based and bash command types for PreToolUse, PostToolUse, Stop, and other events. Covers plugin hooks.json and settings.json formats.
Share bugs, ideas, or general feedback.
Moodle 4.4 introduced a typed Hooks API (core\hook\manager) replacing the unmaintainable jungle of magic callback functions like <plugin>_extend_navigation, <plugin>_before_http_headers, <plugin>_extend_settings_navigation, etc. Hooks are real classes with typed payloads, dispatched through \core\di::get(\core\hook\manager::class). Plugins register interest via db/hooks.php.
Skip when: the event you care about is a \core\event\* (Events 2 API — different system, used for audit/logging). Hooks are for modifying behavior; Events are for reacting to facts.
| Concept | Where it lives | Purpose |
|---|---|---|
| Hook class | classes/hook/<name>.php | Typed payload, optional setters for listeners to mutate |
| Listener registration | db/hooks.php | Maps hook class -> callback (Class::method) + priority |
| Dispatcher call | Core or plugin code | \core\di::get(\core\hook\manager::class)->dispatch(new \plugin\hook\thing(...)); |
| Listener method | Any class | Static or instance method taking the hook instance |
db/hooks.php:
<?php
defined('MOODLE_INTERNAL') || die();
$callbacks = [
[
'hook' => \core\hook\output\before_standard_top_of_body_html_generation::class,
'callback' => \local_example\hook_listener::class . '::inject_banner',
'priority' => 100, // higher runs first
],
];
classes/hook_listener.php:
<?php
namespace local_example;
use core\hook\output\before_standard_top_of_body_html_generation as hook;
class hook_listener {
public static function inject_banner(hook $hook): void {
global $USER;
if (isguestuser() || !isloggedin()) {
return;
}
$hook->add_html('<div class="alert alert-info">Hello, ' . s($USER->firstname) . '</div>');
}
}
After adding or changing db/hooks.php, purge caches: php admin/cli/purge_caches.php.
classes/hook/before_widget_render.php:
<?php
namespace local_example\hook;
use core\hook\described_hook_interface;
use core\hook\stoppable_event_interface;
class before_widget_render implements described_hook_interface, stoppable_event_interface {
private bool $stopped = false;
private string $html = '';
public function __construct(public readonly int $widgetid, public readonly \context $context) {}
public static function get_hook_description(): string {
return 'Dispatched before a widget renders. Listeners may append HTML or veto rendering.';
}
public static function get_hook_tags(): array {
return ['output', 'widget'];
}
public function add_html(string $html): void { $this->html .= $html; }
public function get_html(): string { return $this->html; }
public function stop(): void { $this->stopped = true; }
public function isPropagationStopped(): bool { return $this->stopped; }
}
Dispatch:
$hook = new \local_example\hook\before_widget_render($widgetid, $context);
\core\di::get(\core\hook\manager::class)->dispatch($hook);
if ($hook->isPropagationStopped()) {
return ''; // veto
}
echo $hook->get_html();
| Legacy callback | Replacement hook |
|---|---|
<plugin>_extend_navigation | \core\hook\navigation\primary_extend (4.5+) — check core for current name |
<plugin>_before_http_headers | \core\hook\output\before_http_headers |
<plugin>_before_standard_top_of_body_html | \core\hook\output\before_standard_top_of_body_html_generation |
<plugin>_before_footer | \core\hook\output\before_footer_html_generation |
<plugin>_after_config | \core\hook\after_config |
<plugin>_extend_settings_navigation | check \core\hook\navigation\* for current name |
Migration steps:
grep -rn "function.*_extend_navigation\|_before_http_headers\|_after_config" .lib/classes/hook/ of your Moodle install.db/hooks.php mapping; move the callback body into a listener class.lib.php.version.php, purge caches, run tests.<?php
namespace local_example;
defined('MOODLE_INTERNAL') || die();
final class hook_listener_test extends \advanced_testcase {
/** @covers \local_example\hook_listener::inject_banner */
public function test_banner_injected_for_logged_in_user(): void {
$this->resetAfterTest();
$this->setUser($this->getDataGenerator()->create_user());
$hook = new \core\hook\output\before_standard_top_of_body_html_generation();
\core\di::get(\core\hook\manager::class)->dispatch($hook);
$this->assertStringContainsString('Hello,', $hook->get_output());
}
public function test_skipped_for_guest(): void {
$this->resetAfterTest();
$this->setGuestUser();
$hook = new \core\hook\output\before_standard_top_of_body_html_generation();
\core\di::get(\core\hook\manager::class)->dispatch($hook);
$this->assertStringNotContainsString('Hello,', $hook->get_output());
}
}
php admin/cli/hooks_list.php # all hooks + listeners
php admin/cli/hooks_list.php --hook=core\\hook\\output\\before_http_headers
db/hooks.php change. Listener registration is cached.stoppable_event_interface if vetoing is meaningful. Most output hooks are not stoppable.setUp() — they can have side effects.manager via \core\di::get(...). Don't new it.requires in version.php.db/hooks.php exists with correct hook class FQCNclasses/ with PSR-4)version.php bumped