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
Content types, blocks and widgets
Apps can extend the content model in three ways:
- Content types add a new kind of entry, like Events or Products, with their own admin screens and public pages.
- Blocks add new pieces to the post editor.
- Widgets add reusable boxes. Site owners place them in theme widget areas, in posts (through the Widget block) and on the dashboard.
New in 1.2.94: content types get public pages (
/events/{slug}and an/eventsarchive), their own admin list, the sitemap and site search. Apps can also register taxonomies and custom fields. This page gives the overview; the full reference is Content types, taxonomies and custom fields.
Content types
public function boot(): void
{
$this->registerPostType('event', [
'label' => 'Events',
'singular' => 'Event',
'icon' => 'calendar',
'supports' => ['title', 'content', 'excerpt', 'thumbnail'],
'taxonomies' => ['category'],
]);
}
This gives you:
- a sidebar entry, with All and Add New under it;
- the type's own list, and the create, edit and trash screens at
/admin/content/event; - the block editor, showing the panels the type supports and its taxonomies;
- public pages at
/events/{slug}and an archive at/events(1.2.94), listed in the sitemap and site search; $this->api()->content('event')for CRUD from code.
| Argument | Default | Notes |
|---|---|---|
label, singular | from the slug | Shown in the admin |
description | '' | |
icon | document-text | A Heroicon name |
public | true | false: admin only, no public pages |
rewrite | the slug + s | URL prefix (1.2.94): /events/{slug}; false for none |
supports | all | Editor panels: excerpt, thumbnail, comments. Title, content and slug are always there |
taxonomies | [] | e.g. ['category'], or a taxonomy your app registers |
menu | true | Add a sidebar entry |
capability_type | post | Which capability family governs it (edit_posts, publish_posts…) |
Things to know:
- Registration lasts one request and stores nothing. Do it in
boot()every time. When the app is deactivated the screens disappear; the rows stay in the database and come back when the app does. postandpagecan't be registered, because core owns them. The type slug is stored inposts.type.- Keep
capability_typeaspostunless you have created matching capabilities. No role hasedit_events, so a type demanding it would be invisible to everyone. - Hooks fire for your type too.
post.created,post.updatedand the rest receive rows withtype = 'event'.
Reading and writing from code
$events = $this->api()->content('event');
$id = $events->create([
'title' => 'Basehim meetup',
'excerpt' => 'Lahore, 20 November',
'content' => '<p>Talks and coffee.</p>',
'status' => 'published',
]);
$upcoming = $events->paginate(['status' => 'published'], 1, 5)['data'];
Showing them on the site
Since 1.2.94 a public type has pages of its own: items at /events/{slug} and the published list at /events. Themes render them with single-event.php and archive-event.php when they have them, otherwise single.php and archive.php, and can list items anywhere with bh_query(['type' => 'event']). An app that serves the URLs itself keeps them: its own routes win, or it registers the type with 'rewrite' => false. See Content types, taxonomies and custom fields and Public routes for apps.
Note: Before 1.2.94, a content type had no public pages, its admin list showed posts instead of its own items, and the sitemap listed its entries at
/{slug}, an address that answered 404. All three are fixed in 1.2.94.
Widgets
A widget is a function that returns HTML, plus a few settings for the site owner to fill in:
$this->registerWidget('upcoming', [
'title' => 'Upcoming events',
'description' => 'The next few published events.',
'icon' => 'calendar',
'fields' => [
['key' => 'count', 'label' => 'How many', 'type' => 'number', 'default' => 3],
['key' => 'title', 'label' => 'Heading', 'type' => 'text'],
],
'render' => function (array $settings, string $surface): string {
$count = max(1, min(10, (int) ($settings['count'] ?? 3)));
$events = $this->api()->content('event')->paginate(['status' => 'published'], 1, $count)['data'] ?? [];
if (!$events) return '<p>No events yet.</p>';
$html = '';
if (!empty($settings['title'])) $html .= '<h3>' . htmlspecialchars($settings['title']) . '</h3>';
$html .= '<ul class="upcoming-events">';
foreach ($events as $e) {
$html .= '<li><strong>' . htmlspecialchars($e['title']) . '</strong>'
. ($e['excerpt'] ? ' — ' . htmlspecialchars($e['excerpt']) : '') . '</li>';
}
return $html . '</ul>';
},
]);
- The key is prefixed with your app's slug:
upcomingbecomesevent-manager.upcoming. A key belongs to whoever registered it first,core.*belongs to core, and a refused registration is written to your app's log. fieldsis a list of['key', 'label', 'type', 'default', 'options', 'help']. Types:text,textarea,number,checkbox,select(withoptions),urlandcolor.surfaceslimits where it appears: any offrontend(widget areas),editor(the Widget block) anddashboard. The default is all three.capabilitylimits who may see it, on every surface; the default,read, means every signed-in user.- On the dashboard, a widget can return plain data (stats, lists, tables, charts, feeds, status checks) that core draws for you, with settings, actions, caching and per-user layouts. Its options go under a
dashboardkey. See Dashboard widgets. renderreceives the saved settings and the surface it's drawn on. Escape everything. A widget that throws costs only that widget.
Blocks
A custom block has two halves:
- a JavaScript definition that the editor uses to show and edit it;
- a PHP renderer that produces the HTML on the public site.
The editor half
// boot()
$this->addEditorScript($this->asset('editor.js'));
// assets/editor.js
BasehimEditor.registerBlock('event-manager/notice', {
title: 'Notice',
icon: 'exclamation-triangle',
category: 'widgets',
description: 'A highlighted notice.',
keywords: ['alert', 'warning'],
defaults: { text: '', tone: 'info' },
richField: 'text', // Enter splits, Backspace merges, like a paragraph
edit: function (el, block, api, ctx) {
el.className = 'notice notice--' + (block.data.tone || 'info');
el.appendChild(ctx.rich({ field: 'text', tagName: 'p', placeholder: 'Write a notice…' }));
},
inspector: function (el, block, api, ui) { // the Block tab of the sidebar
ui.select('Tone', block.data.tone, [['info', 'Info'], ['warning', 'Warning']], function (v) {
api.updateBlock(block.id, { tone: v });
});
},
save: function (block) { // optional fallback HTML, stored in data.html
return '<p class="notice notice--' + block.data.tone + '">' + block.data.text + '</p>';
}
});
Name blocks {your-slug}/{block}. In the editor, ui offers text, textarea, number, range, select, toggle, buttons, media, help and panel. Container blocks set container: true and render their children with ctx.inner(element).
The editor API also has:
addToolbarButton,addBlockActionandaddSidebarPanelfor your own controls (for fields stored with the post, useregisterFields()instead: core draws, checks and saves them);on('init' | 'change' | 'save' | …)for events;getBlocks,insertBlock,updateBlockandserializeto work with the content;saveDraft()andpreview()(1.2.94).
The public half
$this->registerBlockRenderer('event-manager/notice', function (array $data, array $block): string {
$tone = ($data['tone'] ?? 'info') === 'warning' ? 'warning' : 'info';
return '<p class="notice notice--' . $tone . '">'
. htmlspecialchars(strip_tags((string) ($data['text'] ?? ''))) . '</p>';
});
If there is no renderer (for example, your app is deactivated), the block's saved data.html from save() is printed instead. That HTML is filtered like any post HTML when the writer lacks unfiltered_html. Always register a renderer: it decides the markup on your terms, and it escapes the data.
How posts store blocks
A block post's content is JSON, with content_format set to blocks:
{"version":1,"blocks":[
{"id":"b_1","type":"heading","data":{"text":"Schedule","level":2}},
{"id":"b_2","type":"event-manager/notice","data":{"text":"Doors open at 6","tone":"warning"}}
]}
Core blocks include paragraph, heading, list, quote, code, image, gallery, embed, columns, group, table, buttons, details, html and widget. You can write this JSON directly through the REST API or MCP when you create posts from code.
Leave a comment