Building Custom Hooks in MediaWiki: Advanced Development Techniques
Why “just another extension” isn’t enough
When you first dip your toes into MediaWiki development, you’ll see a sea of Extension::register calls, a smorgasbord of $wgHooks arrays, and a dozen examples that all look a bit too tidy. In practice, real‑world wikis need more than the stock “hello‑world” example. They need a way to sprint in custom logic at moments the core never imagined you’d care about – say, right after a user saves a page that contains a certain template, or just before the edit form is rendered for a particular namespace.
That’s the sweet spot where custom hooks shine. They let you slot your own code into the MediaWiki lifecycle without hacking the core. Below is a compact, hands‑on guide that walks through the advanced bits, from declaring a hook in extension.json to wiring it into the new HookRunner architecture, with a sprinkle of testing tips and performance caveats.
Understanding the hook landscape
MediaWiki’s hook system has two major flavors:
- Core‑provided hooks – the fifty‑odd events that ships with the software, documented on the Manual:Hooks page.
- Custom hooks – those you invent yourself, usually inside an extension, to expose a niche moment that only your code cares about.
In older versions you’d just slap a function onto $wgHooks['SomeHook']. Starting with MW 1.35 the preferred route is the HookRegistry bean that lives in the ExtensionRegistry. It gives you lazy loading, better namespacing, and clearer error messages.
Legacy vs. modern registration
Legacy:
&$wgHooks['PageContentSave'][] = 'MyExtensionHooks::onPageContentSave';Modern (extension.json):
{
"Hooks": {
"PageContentSave": "MyExtension\\Hooks::onPageContentSave"
}
}Notice the double back‑slashes – that’s just PHP‑namespacing. The modern approach auto‑loads the class via Composer’s autoloader (or MediaWiki’s own class loader), meaning you don’t need a global include.
Defining a custom hook
Let’s say you need a hook that fires right after any article in the “Project” namespace is rendered, and you want to sprinkle in a banner. First, you declare the hook in extension.json:
{
"Hooks": {
"PageContentSave": "MyExtension\\Hooks::onPageContentSave"
},
"CustomHooks": {
"ProjectViewBanner": {
"Description": "Called after rendering a Project page, before output is sent to the client.",
"Parameters": [
{ "name": "$article", "type": "Article" },
{ "name": "$output", "type": "OutputPage" }
]
}
}
}That CustomHooks node is optional but extremely handy – it feeds into the documentation generator and serves as a self‑documenting contract for other developers.
Implementing the trigger
Inside your extension’s main PHP file, you trigger the custom hook via HookContainer::run. Since MW 1.36 the global Hooks facade is discouraged; you fetch the container from the service locator.
use MediaWiki\HookContainer\HookContainer;
class ProjectViewBannerHook {
public static function onArticleViewHeader( Article $article, OutputPage $out ) {
$services = MediaWikiServices::getInstance();
/** @var HookContainer $hookContainer */
$hookContainer = $services->getHookContainer();
// Fire the custom hook. If any handler returns false, abort.
$result = $hookContainer->run( 'ProjectViewBanner', [ $article, $out ] );
// $result is a HookRunnerResult object; we typically ignore it unless
// we need to know if any handler aborted the chain.
}
}Now any other extension can add a handler for ProjectViewBanner without touching your code.
Advanced handler patterns
So far we’ve shown the classic “static method”. You can also register anonymous functions, closures that capture state, or even services pulled from the DI container.
Using a service class
Imagine you need a logger that respects the wiki’s logging configuration. Declare a service in extension.json:
{
"Services": {
"MyExtensionLogger": "MyExtension\\Services\\Logger"
}
}Then write the handler to request that service:
use Psr\Log\LoggerInterface;
class MyExtensionHooks {
public static function onProjectViewBanner( Article $article, OutputPage $out ) {
$logger = MediaWikiServices::getInstance()
->get( 'MyExtensionLogger' ); // returns LoggerInterface
$title = $article->getTitle()->getPrefixedText();
$logger->info( "Project view rendered: $title" );
// Append a simple banner.
$out->addHTML( '<div class="project-banner">Welcome to the project page!</div>' );
}
}Note the use of addHTML instead of addWikiTextAsContent; you’re dealing with raw output here, so a quick HTML snippet is fine.
Returning false to short‑circuit
Hook handlers can return false to stop the chain. This is useful if your custom hook is meant to be a gatekeeper.
class PermissionHooks {
public static function onProjectViewBanner( Article $article, OutputPage $out ) {
$user = $out->getUser();
if ( !$user->isAllowed( 'viewprojectbanner' ) ) {
// Prevent the banner for this user.
return false;
}
// Let other handlers run.
return true;
}
}When a handler returns false, later handlers are skipped and the original caller receives a HookRunnerResult marking the abort. Most callers ignore the result, but if you need to react, you can inspect it directly.
Testing hooks the right way
Hook logic often lives at the edge of MediaWiki core, making it a prime candidate for unit tests. Use the built‑in MediaWiki test harness (PHPUnit) and the HookContainer mock.
class ProjectViewBannerTest extends MediaWikiIntegrationTestCase {
public function testBannerAddsHTML() {
$article = $this->getMockBuilder( Article::class )
->disableOriginalConstructor()
->getMock();
$out = new OutputPage();
// Call the hook directly.
MyExtensionHooks::onProjectViewBanner( $article, $out );
$this->assertStringContainsString( 'project-banner', $out->getHTML() );
}
public function testHookCanBeCancelled() {
$services = MediaWikiServices::getInstance();
$hookContainer = $this->createMock( HookContainer::class );
$hookContainer->method( 'run' )
->with( 'ProjectViewBanner', $this->anything() )
->willReturn( ( new HookRunnerResult() )->setAborted( true ) );
$services->method( 'getHookContainer' )
->willReturn( $hookContainer );
// Simulate the core calling our trigger.
$result = ProjectViewBannerHook::onArticleViewHeader( $article, $out );
// No exception means we honoured the abort.
$this->assertTrue( true );
}
}The first test checks that the HTML was indeed inserted. The second shows how to fake an abort without executing any real handler – handy when you want to verify that your code respects a short‑circuit.
Performance gotchas
Hooks are powerful, but they’re also a potential performance sink if misused. A few rules of thumb:
- Avoid heavy database queries in a hook that fires on every page view. Cache results with
$wgMemcor the newWANObjectCache. - Don’t load libraries you never use. Use lazy loading via the DI container or
MediaWikiServices::getInstance()->get( 'MyService' )only when needed. - Keep the hook logic short. If you need to do a lot of work, defer it to a background job queue (
QueueWorker) or a deferred update.
For example, a naive hook that runs a SELECT * on every PageContentSave will bring down a wiki with a few hundred concurrent editors. Instead, ask “Do I really need that data now?” If the answer is “no,” move it to a post‑save job.
Edge cases and best practices
When you start mixing custom and core hooks, a few subtle pitfalls surface.
- Hook naming collisions. MediaWiki forces global uniqueness. Prefix your custom hook with your extension name, e.g.,
MyExtensionProjectViewBanner. It’s a small price for avoiding tragic “already registered” errors. - Handling optional parameters. Some core hooks pass parameters by reference (e.g.,
&$text). If you forget the reference sign, PHP will throw a warning. Always copy the signature from the Manual page. - Version compatibility. Hook signatures sometimes change. Guard against this by checking
method_existson the hook container before registering, or usingHooks::registerwith a fallback handler.
Putting it all together – a miniature extension
Below is a compact, fully‑functional extension skeleton that showcases everything we’ve covered. Feel free to paste it into MyBanner/extension.json and MyBanner/MyBanner.php on a test wiki.
{
"name": "MyBanner",
"author": "Your Name",
"url": "https://example.org",
"version": "1.0.0",
"manifest_version": 2,
"type": "extension",
"Hooks": {
"ArticleViewHeader": "MyBanner\\Hooks::onArticleViewHeader"
},
"CustomHooks": {
"MyBannerAddBanner": {
"Description": "Insert a custom HTML banner into Project pages.",
"Parameters": [
{ "name": "$article", "type": "Article" },
{ "name": "$output", "type": "OutputPage" }
]
}
},
"MessagesDirs": {
"MyBanner": [
"i18n"
]
}
}<?php
namespace MyBanner;
use MediaWiki\MediaWikiServices;
use MediaWiki\HookContainer\HookContainer;
use Article;
use OutputPage;
class Hooks {
public static function onArticleViewHeader( Article $article, OutputPage $out ) {
// Only act on the Project namespace (ID 4 by default)
if ( $article->getTitle()->getNamespace() !== 4 ) {
return;
}
$services = MediaWikiServices::getInstance();
$hookContainer = $services->getHookContainer();
// Let any other extension insert its own banner first
$hookContainer->run( 'MyBannerAddBanner', [ $article, $out ] );
// Default banner if none added
if ( strpos( $out->getHTML(), 'project-banner' ) === false ) {
$out->addHTML( '<div class="project-banner">Default MyBanner text</div>' );
}
}
}
?>That’s it. Install the extension, clear the cache (maintenance/update.php or php wiki reload), and you’ll see a banner on every Project page. Other developers can now hook into MyBannerAddBanner to replace or augment the default output.
Wrapping up thoughts
Building custom hooks isn’t a gimmick; it’s a disciplined way to keep your code modular, testable, and future‑proof. By registering hooks through extension.json, leveraging the DI container, and respecting the abort semantics, you get a clean contract that survives MediaWiki upgrades. Remember to profile any hook that runs on high‑traffic paths, and always write at least one test – otherwise you’ll be chasing bugs that only surface after a weekend of heavy editing.
In the end, a well‑placed hook feels like a hidden lever under the wiki’s hood: you press it, a new feature flares up, and the core keeps humming along, blissfully unaware of the extra machinery you just attached.