Skip to content

Content types, blocks and widgets

5 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.
  • 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.
ArgumentDefaultNotes
label, singularfrom the slugShown in the admin
description''
icondocument-textA Heroicon name
supports['title', 'content', 'author']Editor panels: title, content, excerpt, thumbnail, author
taxonomies[]e.g. ['category']
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

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.xml at /{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: upcoming becomes event-manager.upcoming.
  • fields is a list of ['key', 'label', 'type', 'default', 'options', 'help']. Types: text, textarea, number, checkbox and select (with options).
  • surfaces limits where it appears: any of frontend (widget areas), editor (the Widget block) and dashboard. The default is all three.
  • dashboard sets its size on the dashboard: ['width' => 'full'|'half'|'third', 'priority' => 10].
  • 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;
  • on('init' | 'change' | 'save' | …) for events;
  • getBlocks, insertBlock, updateBlock and serialize to 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.

Keep building

More guides, or jump straight into the API reference.

Discussion

Leave a comment