Skip to content
Developer docs

Content types, blocks and widgets

Updated 6 min read

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 /events archive), 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.
ArgumentDefaultNotes
label, singularfrom the slugShown in the admin
description''
icondocument-textA Heroicon name
publictruefalse: admin only, no public pages
rewritethe slug + sURL prefix (1.2.94): /events/{slug}; false for none
supportsallEditor panels: excerpt, thumbnail, comments. Title, content and slug are always there
taxonomies[]e.g. ['category'], or a taxonomy your app registers
menutrueAdd a sidebar entry
capability_typepostWhich 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.
  • post and page can't be registered, because core owns them. The type slug is stored in posts.type.
  • Keep capability_type as post unless you have created matching capabilities. No role has edit_events, so a type demanding it would be invisible to everyone.
  • Hooks fire for your type too. post.created, post.updated and the rest receive rows with type = '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: upcoming becomes event-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.
  • fields is a list of ['key', 'label', 'type', 'default', 'options', 'help']. Types: text, textarea, number, checkbox, select (with options), url and color.
  • surfaces limits where it appears: any of frontend (widget areas), editor (the Widget block) and dashboard. The default is all three.
  • capability limits 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 dashboard key. See Dashboard widgets.
  • render receives 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:

  1. a JavaScript definition that the editor uses to show and edit it;
  2. 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, addBlockAction and addSidebarPanel for your own controls (for fields stored with the post, use registerFields() instead: core draws, checks and saves them);
  • on('init' | 'change' | 'save' | …) for events;
  • getBlocks, insertBlock, updateBlock and serialize to work with the content;
  • saveDraft() and preview() (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.

Discussion

Leave a comment