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.
- 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.
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;
- the standard list, create, edit and trash screens at
/admin/content/event; - the block editor;
$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 |
supports | ['title', 'content', 'author'] | Editor panels: title, content, excerpt, thumbnail, author |
taxonomies | [] | e.g. ['category'] |
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
A content type has no public page of its own: core's front end serves posts and pages only. Show your entries with a widget (below), a block, or a route of your own (see Admin screens and routes).
Note: In Basehim 1.2.53 and earlier, published entries of an app's content type are listed in
/sitemap.xmlat/{slug}, and that address answers 404. Keep this in mind if you publish many entries before it's fixed.
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. fieldsis a list of['key', 'label', 'type', 'default', 'options', 'help']. Types:text,textarea,number,checkboxandselect(withoptions).surfaceslimits where it appears: any offrontend(widget areas),editor(the Widget block) anddashboard. The default is all three.dashboardsets its size on the dashboard:['width' => 'full'|'half'|'third', 'priority' => 10].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;on('init' | 'change' | 'save' | …)for events;getBlocks,insertBlock,updateBlockandserializeto work with the content.
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