Developer docs
Start
Build with AI
Themes
Apps
- App development
- App development best practices
- Hooks and filters
- The App API
- Sending email from apps
- Admin bar for apps and themes
- Public routes for apps
- Admin screens, routes and UI components
- Dashboard widgets
- Data tables
- AJAX actions
- Media and uploads for apps
- App permissions
- Content types, blocks and widgets
- Content types, taxonomies and custom fields
- Search
- When an app fails: fault isolation and recovery
API and AI agents
Security and contributing
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