Hooks and filters
Hooks let an app react to what core does (actions) and change what core produces (filters). Register them in your app's boot(), or in a theme's boot.php.
Actions and filters
An action tells you something happened. Your callback's return value is ignored:
$this->addAction('post.created', function (array $post) {
$this->log('New post', ['id' => $post['id'], 'title' => $post['title']]);
});
A filter passes a value through every callback. Each one returns the value, changed or not:
$this->addFilter('post.content', function (string $html, ?array $post = null): string {
return $html . '<p class="signature">Thanks for reading.</p>';
}, 20, 2);
The full signatures:
$this->addAction(string $tag, callable $callback, int $priority = 10, int $acceptedArgs = 1);
$this->addFilter(string $tag, callable $callback, int $priority = 10, int $acceptedArgs = 1);
- Priority: lower runs first. Core's own
post.contentrenderer runs at 5, so a filter at 10 or 20 sees finished HTML. - Accepted arguments: how many of the hook's arguments your callback receives. The default is 1. Pass 2 or 3 to get the extra context listed below.
You can fire your own hooks too, so other apps can extend yours:
$this->doAction('event-manager.ticket_sold', $ticket);
$price = $this->applyFilters('event-manager.price', $price, $event);
Prefix your hook names with your app's slug.
Content
| Hook | Type | Arguments | Notes |
|---|---|---|---|
post.before_create | filter | $data | Change the data before a post or page is inserted |
post.created | action | $post | After insert; the full row |
post.before_update | filter | $data, $existing | Change the update before it's saved |
post.updated | action | $post, $existing | After update: the new row and the old one |
post.before_delete | action | $existing | Before it moves to the trash |
post.deleted | action | $existing | After it moved to the trash |
post.before_force_delete | action | $row | Before permanent deletion |
post.restored | action | $restored | Restored from the trash |
post.content | filter | $html, $post | The body on public pages (posts and pages). Runs at render time, not on save |
post.unfiltered_html | filter | $allowed, $user | May this writer save HTML as written? See below |
html.iframe_hosts | filter | $hosts | host/path prefixes that filtered HTML may embed in iframes |
All of these fire for every content type, including page and app types. Check $post['type'] when you only care about one.
Filtered HTML
Post HTML from anyone without the unfiltered_html capability is filtered when it's saved. Editors and administrators have that capability. Filtering removes scripts, event handlers, javascript: links, forms and iframes from unknown hosts. It also applies to posts your app writes while nobody is signed in, such as from a scheduled task. If your app writes HTML it generated and trusts:
$this->addFilter('post.unfiltered_html', function (bool $allowed, ?array $user): bool {
return $allowed || $this->importing; // a flag your import task sets while it runs
}, 10, 2);
To let filtered posts embed another video host:
$this->addFilter('html.iframe_hosts', fn(array $hosts) => [...$hosts, 'player.example.org/embed/']);
Users
| Hook | Type | Arguments |
|---|---|---|
user.before_create | filter | $data |
user.created | action | $user |
user.updated | action | $user, $existing |
user.deleted | action | $existing |
user.avatar_changed | action | $userId |
User rows passed to hooks don't include the password hash.
Comments
| Hook | Type | Arguments |
|---|---|---|
comment.before_create | filter | $data |
comment.created | action | $comment |
comment.status_changed | action | $comment, $previous |
comment.deleted | action | $existing |
comment_form.args | filter | $args, $post (options of bh_comment_form()) |
comment_form.html | filter | $html, $post, $args |
Taxonomies, media and mail
| Hook | Type | Arguments |
|---|---|---|
term.created, term.updated | action | $term |
term.deleted | action | $existing |
media.uploaded | action | $media |
media.deleted | action | $media |
mail.before_send | filter | $mail (to, subject, html…) |
mail.sent | action | $mail |
mail.failed | action | $mail, $error |
activity.logged | action | $row (an activity log entry) |
Front end
| Hook | Type | Arguments | Notes |
|---|---|---|---|
bh.head | action and filter | Use $this->addHead() rather than the raw hook | |
bh.footer | action and filter | Use $this->addFooter() | |
author_box.html | filter | $html, $author, $post | Output of bh_author_box() |
route.miss | filter | null, $request, $path | Return a Response to answer a URL that would otherwise be a 404 |
Admin
| Hook | Type | Arguments | Notes |
|---|---|---|---|
admin.menu | filter | $items | Use $this->addAdminMenu() |
admin.styles, admin.scripts | filter | $urls | Use $this->addAdminStyle() / addAdminScript() |
admin.area_policy | filter | $map | Path prefix ⇒ required capability for admin routes |
admin.badge | filter | $count, $key | The number on a sidebar item |
Editor and blocks
| Hook | Type | Arguments | Notes |
|---|---|---|---|
editor.scripts, editor.styles | filter | $urls | Use $this->addEditorScript() / addEditorStyle() |
editor.config | filter | $config | Values in window.BasehimEditorConfig; use addEditorConfig() |
blocks.pre_render | filter | $blocks | The block list before rendering |
blocks.render.{type} | filter | null, $data, $block | Return HTML to render a block type; use registerBlockRenderer() |
blocks.rendered | filter | $html, $blocks | The finished HTML |
blocks.stylesheet | filter | $url | Another URL, or '' to link no block stylesheet |
SEO
seo.enabled, seo.groups, seo.title, seo.description, seo.canonical, seo.robots, seo.image, seo.jsonld and seo.head. Each receives the value and a context array (pass 10, 2). See SEO, dates and images.
API, MCP and AI discovery
| Hook | Type | Arguments | Notes |
|---|---|---|---|
api.scopes | filter | $scopes (scope => label) | Scopes that API keys can be given |
mcp.scopes | filter | $scopes (list) | Scopes MCP OAuth clients may request |
mcp.scope_capabilities | filter | $map (scope => capability) | Who may grant your scope at OAuth consent; unmapped scopes need manage_options |
mcp.tools | filter | $tools, $scopes | Add MCP tools |
mcp.call.{name} | filter | null, $arguments | Run your tool; return an array or a string |
ai.llms_sections | filter | $sections | Sections of /llms.txt |
ai.catalog_entries | filter | $entries | Entries of /.well-known/ard.json |
ai.robots | filter | $text | The robots.txt body |
ai.tools | filter | $tools | WebMCP tools registered in visitors' browsers |
See Give your app an MCP tool for a worked example of the mcp.* hooks.
Good habits
- Keep callbacks fast. Many hooks run on every page view.
- Return the value from every filter, even when you didn't change it.
- A filter's input may come from users: escape what you print, validate what you store.
- Exceptions in hooks are caught in most places, but don't rely on it: one thrown exception can cost the rest of your callback's work.
Leave a comment