Skip to content

Hooks and filters

4 min read

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.content renderer 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

HookTypeArgumentsNotes
post.before_createfilter$dataChange the data before a post or page is inserted
post.createdaction$postAfter insert; the full row
post.before_updatefilter$data, $existingChange the update before it's saved
post.updatedaction$post, $existingAfter update: the new row and the old one
post.before_deleteaction$existingBefore it moves to the trash
post.deletedaction$existingAfter it moved to the trash
post.before_force_deleteaction$rowBefore permanent deletion
post.restoredaction$restoredRestored from the trash
post.contentfilter$html, $postThe body on public pages (posts and pages). Runs at render time, not on save
post.unfiltered_htmlfilter$allowed, $userMay this writer save HTML as written? See below
html.iframe_hostsfilter$hostshost/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

HookTypeArguments
user.before_createfilter$data
user.createdaction$user
user.updatedaction$user, $existing
user.deletedaction$existing
user.avatar_changedaction$userId

User rows passed to hooks don't include the password hash.

Comments

HookTypeArguments
comment.before_createfilter$data
comment.createdaction$comment
comment.status_changedaction$comment, $previous
comment.deletedaction$existing
comment_form.argsfilter$args, $post (options of bh_comment_form())
comment_form.htmlfilter$html, $post, $args

Taxonomies, media and mail

HookTypeArguments
term.created, term.updatedaction$term
term.deletedaction$existing
media.uploadedaction$media
media.deletedaction$media
mail.before_sendfilter$mail (to, subject, html…)
mail.sentaction$mail
mail.failedaction$mail, $error
activity.loggedaction$row (an activity log entry)

Front end

HookTypeArgumentsNotes
bh.headaction and filterUse $this->addHead() rather than the raw hook
bh.footeraction and filterUse $this->addFooter()
author_box.htmlfilter$html, $author, $postOutput of bh_author_box()
route.missfilternull, $request, $pathReturn a Response to answer a URL that would otherwise be a 404

Admin

HookTypeArgumentsNotes
admin.menufilter$itemsUse $this->addAdminMenu()
admin.styles, admin.scriptsfilter$urlsUse $this->addAdminStyle() / addAdminScript()
admin.area_policyfilter$mapPath prefix ⇒ required capability for admin routes
admin.badgefilter$count, $keyThe number on a sidebar item

Editor and blocks

HookTypeArgumentsNotes
editor.scripts, editor.stylesfilter$urlsUse $this->addEditorScript() / addEditorStyle()
editor.configfilter$configValues in window.BasehimEditorConfig; use addEditorConfig()
blocks.pre_renderfilter$blocksThe block list before rendering
blocks.render.{type}filternull, $data, $blockReturn HTML to render a block type; use registerBlockRenderer()
blocks.renderedfilter$html, $blocksThe finished HTML
blocks.stylesheetfilter$urlAnother 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

HookTypeArgumentsNotes
api.scopesfilter$scopes (scope => label)Scopes that API keys can be given
mcp.scopesfilter$scopes (list)Scopes MCP OAuth clients may request
mcp.scope_capabilitiesfilter$map (scope => capability)Who may grant your scope at OAuth consent; unmapped scopes need manage_options
mcp.toolsfilter$tools, $scopesAdd MCP tools
mcp.call.{name}filternull, $argumentsRun your tool; return an array or a string
ai.llms_sectionsfilter$sectionsSections of /llms.txt
ai.catalog_entriesfilter$entriesEntries of /.well-known/ard.json
ai.robotsfilter$textThe robots.txt body
ai.toolsfilter$toolsWebMCP 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.

Keep building

More guides, or jump straight into the API reference.

Discussion

Leave a comment