2 // This file is part of Moodle - https://moodle.org/
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <https://www.gnu.org/licenses/>.
19 use Psr\EventDispatcher\EventDispatcherInterface
;
20 use Psr\EventDispatcher\ListenerProviderInterface
;
21 use Psr\EventDispatcher\StoppableEventInterface
;
24 * Hook manager implementing "Dispatcher" and "Event Provider" from PSR-14.
26 * Due to class/method naming restrictions and collision with
27 * Moodle events the definitions from PSR-14 should be interpreted as:
30 * 2. Listener --> Hook callback
31 * 3. Emitter --> Hook emitter
32 * 4. Dispatcher --> Hook dispatcher - implemented in manager::dispatch()
33 * 5. Listener Provider --> Hook callback provider - implemented in manager::get_callbacks_for_hook()
35 * Note that technically any object can be a hook, but it is recommended
36 * to put all hook classes into \component_name\hook namespaces and
37 * each hook should implement \core\hook\described_hook interface.
41 * @copyright 2022 Open LMS
42 * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
44 final class manager
implements
45 EventDispatcherInterface
,
46 ListenerProviderInterface
{
48 /** @var ?manager the one instance of listener provider and dispatcher */
49 private static $instance = null;
51 /** @var array list of callback definitions for each hook class. */
52 private $allcallbacks = [];
54 /** @var array list of all deprecated lib.php plugin callbacks. */
55 private $alldeprecations = [];
57 /** @var array list of redirected callbacks in PHPUnit tests */
58 private $redirectedcallbacks = [];
61 * Constructor can be used only from factory methods.
63 private function __construct() {
67 * Factory method, returns instance of manager that serves
68 * as hook dispatcher and callback provider.
72 public static function get_instance(): manager
{
73 if (!self
::$instance) {
74 self
::$instance = new self();
75 self
::$instance->init_standard_callbacks();
77 return self
::$instance;
81 * Factory method for testing of hook manager in PHPUnit tests.
83 * @param array $componentfiles list of hook callback files for each component.
86 public static function phpunit_get_instance(array $componentfiles): manager
{
88 throw new \
coding_exception('Invalid call of manager::phpunit_get_instance() outside of tests');
90 $instance = new self();
91 $instance->load_callbacks($componentfiles);
96 * Override hook callbacks for testing purposes.
98 * @param string $hookname
99 * @param callable $callback
102 public function phpunit_redirect_hook(string $hookname, callable
$callback): void
{
104 throw new \
coding_exception('Invalid call of manager::phpunit_redirect_hook() outside of tests');
106 $this->redirectedcallbacks
[$hookname] = $callback;
110 * Cancel all redirections of hook callbacks.
114 public function phpunit_stop_redirections(): void
{
116 throw new \
coding_exception('Invalid call of manager::phpunit_stop_redirections() outside of tests');
118 $this->redirectedcallbacks
= [];
122 * Returns list of callbacks for given hook name.
124 * NOTE: this is the "Listener Provider" described in PSR-14,
125 * instead of instance parameter it uses real PHP class names.
127 * @param string $hookclassname PHP class name of hook
128 * @return array list of callback definitions
130 public function get_callbacks_for_hook(string $hookclassname): array {
131 return $this->allcallbacks
[$hookclassname] ??
[];
135 * Returns list of all callbacks found in db/hooks.php files.
139 public function get_all_callbacks(): iterable
{
140 return $this->allcallbacks
;
144 * Get the list of listeners for the specified event.
146 * @param object $event The object being listened to (aka hook).
147 * @return iterable<callable>
148 * An iterable (array, iterator, or generator) of callables. Each
149 * callable MUST be type-compatible with $event.
150 * Please note that in Moodle the callable must be a string.
152 public function getListenersForEvent(object $event): iterable
{
153 // Callbacks are sorted by priority, highest first at load-time.
154 $hookclassname = get_class($event);
155 $callbacks = $this->get_callbacks_for_hook($hookclassname);
157 if (count($callbacks) === 0) {
158 // Nothing is interested in this hook.
159 return new \
EmptyIterator();
162 foreach ($callbacks as $definition) {
163 if ($definition['disabled']) {
166 $callback = $definition['callback'];
168 if ($this->is_callback_valid($definition['component'], $callback)) {
175 * Verify that callback is valid.
177 * @param string $component
178 * @param string $callback
181 private function is_callback_valid(string $component, string $callback): bool {
182 [$callbackclass, $callbackmethod] = explode('::', $callback, 2);
183 if (!class_exists($callbackclass)) {
185 "Hook callback definition contains invalid 'callback' class name in '$component'. " .
186 "Callback class '{$callbackclass}' not found.",
191 $rc = new \
ReflectionClass($callbackclass);
192 if (!$rc->hasMethod($callbackmethod)) {
194 "Hook callback definition contains invalid 'callback' method name in '$component'. " .
195 "Callback method not found.",
201 $rcm = $rc->getMethod($callbackmethod);
202 if (!$rcm->isStatic()) {
204 "Hook callback definition contains invalid 'callback' method name in '$component'. " .
205 "Callback method not a static method.",
211 if (!is_callable($callback, false, $callablename)) {
213 "Cannot execute callback '$callablename' from '$component'" .
214 "Callback method not callable.",
224 * Returns the list of Hook class names that have registered callbacks.
228 public function get_hooks_with_callbacks(): array {
229 return array_keys($this->allcallbacks
);
233 * Provide all relevant listeners with an event to process.
235 * @param object $event The object to process (aka hook).
236 * @return object The Event that was passed, now modified by listeners.
238 public function dispatch(object $event): object {
239 // We can dispatch only after the lib/setup.php includes,
240 // that is right before the database connection is made,
241 // the MUC caches need to be working already.
242 if (!function_exists('setup_DB')) {
243 debugging('Hooks cannot be dispatched yet', DEBUG_DEVELOPER
);
248 $hookclassname = get_class($event);
249 if (isset($this->redirectedcallbacks
[$hookclassname])) {
250 call_user_func($this->redirectedcallbacks
[$hookclassname], $event);
255 $callbacks = $this->getListenersForEvent($event);
257 if (empty($callbacks)) {
258 // Nothing is interested in this hook.
262 foreach ($callbacks as $callback) {
263 // Note: PSR-14 states:
264 // If passed a Stoppable Event, a Dispatcher
265 // MUST call isPropagationStopped() on the Event before each Listener has been called.
266 // If that method returns true it MUST return the Event to the Emitter immediately and
267 // MUST NOT call any further Listeners. This implies that if an Event is passed to the
268 // Dispatcher that always returns true from isPropagationStopped(), zero listeners will be called.
269 // Ergo, we check for a stopped event before calling each listener, not afterwards.
270 if ($event instanceof StoppableEventInterface
) {
271 if ($event->isPropagationStopped()) {
276 call_user_func($callback, $event);
279 // Developers need to be careful to not create infinite loops in hook callbacks.
284 * Initialise list of all callbacks for each hook.
288 private function init_standard_callbacks(): void
{
291 $this->allcallbacks
= [];
292 $this->alldeprecations
= [];
295 // @codeCoverageIgnoreStart
296 if (!PHPUNIT_TEST
&& !CACHE_DISABLE_ALL
) {
297 $cache = \cache
::make('core', 'hookcallbacks');
298 $callbacks = $cache->get('callbacks');
299 $deprecations = $cache->get('deprecations');
300 $overrideshash = $cache->get('overrideshash');
302 $usecache = is_array($callbacks);
303 $usecache = $usecache && is_array($deprecations);
304 $usecache = $usecache && $this->calculate_overrides_hash() === $overrideshash;
306 $this->allcallbacks
= $callbacks;
307 $this->alldeprecations
= $deprecations;
311 // @codeCoverageIgnoreEnd
313 // Get list of all files with callbacks, one per component.
314 $components = ['core' => "{$CFG->dirroot}/lib/db/hooks.php"];
315 $plugintypes = \core_component
::get_plugin_types();
316 foreach ($plugintypes as $plugintype => $plugintypedir) {
317 $plugins = \core_component
::get_plugin_list($plugintype);
318 foreach ($plugins as $pluginname => $plugindir) {
323 $components["{$plugintype}_{$pluginname}"] = "{$plugindir}/db/hooks.php";
327 // Load the callbacks and apply overrides.
328 $this->load_callbacks($components);
331 $cache->set('callbacks', $this->allcallbacks
);
332 $cache->set('deprecations', $this->alldeprecations
);
333 $cache->set('overrideshash', $this->calculate_overrides_hash());
338 * Load callbacks from component db/hooks.php files.
340 * @param array $componentfiles list of all components with their callback files
343 private function load_callbacks(array $componentfiles): void
{
344 $this->allcallbacks
= [];
345 $this->alldeprecations
= [];
348 [$this, 'add_component_callbacks'],
349 array_keys($componentfiles),
352 $this->load_callback_overrides();
353 $this->prioritise_callbacks();
354 $this->fetch_deprecated_callbacks();
358 * In extremely special cases admins may decide to override callbacks via config.php setting.
360 private function load_callback_overrides(): void
{
363 if (!property_exists($CFG, 'hooks_callback_overrides')) {
367 if (!is_iterable($CFG->hooks_callback_overrides
)) {
368 debugging('hooks_callback_overrides must be an array', DEBUG_DEVELOPER
);
372 foreach ($CFG->hooks_callback_overrides
as $hookclassname => $overrides) {
373 if (!is_iterable($overrides)) {
374 debugging('hooks_callback_overrides must be an array of arrays', DEBUG_DEVELOPER
);
378 if (!array_key_exists($hookclassname, $this->allcallbacks
)) {
379 debugging('hooks_callback_overrides must be an array of arrays with existing hook classnames', DEBUG_DEVELOPER
);
383 foreach ($overrides as $callback => $override) {
384 if (!is_array($override)) {
385 debugging('hooks_callback_overrides must be an array of arrays', DEBUG_DEVELOPER
);
390 foreach ($this->allcallbacks
[$hookclassname] as $index => $definition) {
391 if ($definition['callback'] === $callback) {
392 if (isset($override['priority'])) {
393 $definition['defaultpriority'] = $definition['priority'];
394 $definition['priority'] = (int) $override['priority'];
397 if (!empty($override['disabled'])) {
398 $definition['disabled'] = true;
401 $this->allcallbacks
[$hookclassname][$index] = $definition;
407 debugging("Unable to find callback '{$callback}' for '{$hookclassname}'", DEBUG_DEVELOPER
);
414 * Calculate a hash of the overrides.
415 * This is used to inform if the overrides have changed, which invalidates the cache.
417 * Overrides are only configured in config.php where there is no other mechanism to invalidate the cache.
419 * @return null|string
421 private function calculate_overrides_hash(): ?
string {
424 if (!property_exists($CFG, 'hooks_callback_overrides')) {
428 if (!is_iterable($CFG->hooks_callback_overrides
)) {
432 return sha1(json_encode($CFG->hooks_callback_overrides
));
436 * Prioritise the callbacks.
438 private function prioritise_callbacks(): void
{
439 // Prioritise callbacks.
440 foreach ($this->allcallbacks
as $hookclassname => $hookcallbacks) {
441 \core_collator
::asort_array_of_arrays_by_key($hookcallbacks, 'priority', \core_collator
::SORT_NUMERIC
);
442 $hookcallbacks = array_reverse($hookcallbacks);
443 $this->allcallbacks
[$hookclassname] = $hookcallbacks;
448 * Fetch the list of callbacks that this hook replaces.
450 private function fetch_deprecated_callbacks(): void
{
451 $candidates = self
::discover_known_hooks();
453 /** @var class-string<deprecated_callback_replacement> $hookclassname */
454 foreach (array_keys($candidates) as $hookclassname) {
455 if (!class_exists($hookclassname)) {
458 if (!is_subclass_of($hookclassname, \core\hook\deprecated_callback_replacement
::class)) {
461 $deprecations = $hookclassname::get_deprecated_plugin_callbacks();
462 if (!$deprecations) {
465 foreach ($deprecations as $deprecation) {
466 $this->alldeprecations
[$deprecation][] = $hookclassname;
472 * Add hook callbacks from file.
474 * @param string $component component where hook callbacks are defined
475 * @param string $hookfile file with list of all callbacks for component
478 private function add_component_callbacks(string $component, string $hookfile): void
{
479 if (!file_exists($hookfile)) {
483 $parsecallbacks = function($hookfile) {
489 $callbacks = $parsecallbacks($hookfile);
491 if (!is_array($callbacks) ||
!$callbacks) {
495 foreach ($callbacks as $callbackdata) {
496 if (empty($callbackdata['hook'])) {
497 debugging("Hook callback definition requires 'hook' name in '$component'", DEBUG_DEVELOPER
);
501 $callbackmethod = $this->normalise_callback($component, $callbackdata);
502 if ($callbackmethod === null) {
507 'callback' => $callbackmethod,
508 'component' => $component,
513 if (isset($callbackdata['priority'])) {
514 $callback['priority'] = (int) $callbackdata['priority'];
517 $hook = ltrim($callbackdata['hook'], '\\'); // Normalise hook class name.
518 $this->allcallbacks
[$hook][] = $callback;
523 * Normalise the callback class::method value.
525 * @param string $component
526 * @param array $callback
527 * @return null|string
529 private function normalise_callback(string $component, array $callback): ?
string {
530 if (empty($callback['callback'])) {
531 debugging("Hook callback definition requires 'callback' callable in '$component'", DEBUG_DEVELOPER
);
534 $classmethod = $callback['callback'];
535 if (!is_string($classmethod)) {
536 debugging("Hook callback definition contains invalid 'callback' string in '$component'", DEBUG_DEVELOPER
);
539 if (!str_contains($classmethod, '::')) {
541 "Hook callback definition contains invalid 'callback' static class method string in '$component'",
547 // Normalise the callback class::method name, we use it later as an identifier.
548 $classmethod = ltrim($classmethod, '\\');
554 * Is the plugin callback from lib.php deprecated by any hook?
556 * @param string $plugincallback short callback name without the component prefix
559 public function is_deprecated_plugin_callback(string $plugincallback): bool {
560 return isset($this->alldeprecations
[$plugincallback]);
564 * Is there a hook callback in component that deprecates given lib.php plugin callback?
566 * NOTE: if there is both hook and deprecated callback then we ignore the old callback
567 * to allow compatibility of contrib plugins with multiple Moodle branches.
569 * @param string $component
570 * @param string $plugincallback short callback name without the component prefix
573 public function is_deprecating_hook_present(string $component, string $plugincallback): bool {
574 if (!isset($this->alldeprecations
[$plugincallback])) {
578 foreach ($this->alldeprecations
[$plugincallback] as $hookclassname) {
579 if (!isset($this->allcallbacks
[$hookclassname])) {
582 foreach ($this->allcallbacks
[$hookclassname] as $definition) {
583 if ($definition['component'] === $component) {
593 * Returns list of hooks discovered through hook namespaces or discovery agents.
595 * The hooks overview page includes also all other classes that are
596 * referenced in callback registrations in db/hooks.php files, those
597 * are not included here.
599 * @return array hook class names
601 public static function discover_known_hooks(): array {
602 // All classes in hook namespace of core and plugins, unless plugin has a discovery agent.
603 $hooks = \core\hooks
::discover_hooks();
605 // Look for hooks classes in all plugins that implement discovery agent interface.
606 foreach (\core_component
::get_component_names() as $component) {
607 $classname = "{$component}\\hooks";
609 if (!class_exists($classname)) {
613 if (!is_subclass_of($classname, discovery_agent
::class)) {
617 $hooks = array_merge($hooks, $classname::discover_hooks());