Skip to content
Developer docs

Content types, taxonomies and custom fields

8 min read

Since Basehim 1.2.94 an app can add its own kinds of content, such as events, products or recipes, along with the taxonomies that group them and the extra fields each item carries. Core gives them everything posts have: an admin list and editor, public pages, the sitemap, site search, the REST API and theme helpers. The app only declares them.

public function boot(): void
{
    $this->registerPostType('event', [
        'label'      => 'Events',
        'singular'   => 'Event',
        'icon'       => 'calendar',
        'supports'   => ['title', 'content', 'excerpt', 'thumbnail'],
        'taxonomies' => ['category'],          // core's, shared with posts
    ]);
    $this->registerTaxonomy('event-type', [
        'label' => 'Event types', 'singular' => 'Event type',
        'hierarchical' => true, 'post_types' => ['event'],
    ]);
    $this->registerFields('event', [
        ['key' => 'starts_at', 'label' => 'Starts', 'type' => 'date', 'required' => true],
        ['key' => 'venue', 'label' => 'Venue', 'type' => 'text', 'maxlength' => 120],
        ['key' => 'seats', 'label' => 'Seats', 'type' => 'number', 'min' => 1, 'default' => 50],
        ['key' => 'online', 'label' => 'Online event', 'type' => 'checkbox'],
        ['key' => 'stream_url', 'label' => 'Stream link', 'type' => 'url', 'show_if' => ['online' => '1']],
    ], ['title' => 'Event details']);
}

That is the whole app side. Events are now at /events/summer-meetup, listed at /events, edited under Events in the admin sidebar, and their fields are a panel in the editor's sidebar.

Registrations are per request, made in boot(). When the app is switched off, its types, taxonomies and fields stop existing, but the content, terms and field values stay in the database. They come back when the app is switched on again.

Content types

registerPostType($key, $args). The key is lower-case letters, digits, - and _, and is stored as the item's type. post and page belong to core.

ArgumentDefaultMeaning
label, singular{Key}s, {Key}Names in the admin and on archive pages
description''Shown on the archive page by themes that print it
icondocument-textSidebar icon (a Heroicons name)
publictruefalse: admin only. No URLs, and not in the sitemap, search or REST API
rewritekey + sURL prefix: items at /{rewrite}/{slug}, archive at /{rewrite}. false: no core URLs (the app serves them itself)
has_archivetrueWhether /{rewrite} lists the published items
per_pageSettings → ReadingItems per archive page
supportsallEditor panels: excerpt, thumbnail (featured image), comments (the Discussion panel, and whether comments are accepted). Title, content, status and slug are always there
taxonomies[]Taxonomies for this type: category, tag, or an app's
searchableas publicWhether published items appear in site search
menutruefalse hides it from the sidebar; a string names the sidebar section
capability_typepostCapability family: edit_posts, publish_posts …
  • Prefixes. One of core's own paths (admin, api, posts, page, category, tag, search …) or another type's prefix is refused. The type then has no URLs, and the reason is in PostTypeRegistry::rejected().
  • Apps' own routes win. The type's URLs are tried after apps' own routes and before the page lookup, so an app that already serves /events keeps it.
  • Pages with the same slug. A page whose slug equals a type's prefix moves to /page/{slug}, as it does for any route.
  • Slugs are unique within their type.

Taxonomies

registerTaxonomy($key, $args) groups content the way categories and tags group posts.

ArgumentDefaultMeaning
label, singularNames
hierarchicalfalseTerms can have parents
post_types[]Types it applies to. A type's own taxonomies list attaches it too
publictrueTerm pages at /{rewrite}/{term}
rewritethe keyURL prefix for term pages; false for none

The taxonomy is stored the first time it is registered, so terms work like categories everywhere: you add them in the admin (under the type's menu) or with api()->terms()->create('event-type', [...]). In the editor, every taxonomy of a type is a panel of checkboxes; category and tag keep their usual panels. Core's category and tag pages also list a type's items when the type uses them.

Custom fields

registerFields($type, $fields, $group) declares a group of fields for an app's type, or for post or page. Each group is one panel in the editor, titled by $group['title'] (default "Fields") and optionally described by description. Registering the same title again replaces the group.

Fields use the same settings schema as frontend widgets: key, label, type, default, required, help, options, min / max / step / maxlength, show_if, capability and private. Types: text, textarea, html, number, checkbox, select, multiselect, radio, url, color, date, terms (with taxonomy and multiple), posts and media.

  • Keys are lower-case letters, digits and _, and unique per type. A key another app has already declared on the same type is refused, so apps can't overwrite each other's data. Keys starting with _ are reserved for core.
  • Saving. The editor form, the editor's draft save and saveFields() all check values against the schema. An invalid value is not saved and keeps its previous value. The post itself is still saved, and the editor names the fields that need fixing. An empty required field warns on publish but doesn't block it.
  • Reading goes through the schema too: every declared field is present, of its declared type, with its default when nothing was saved.
  • Private fields ('private' => true) are left out of the theme helpers, the REST API and MCP. The app reads them with fields().
  • Storage is the existing post meta table, one row per field (lists are stored as JSON). Moving a post to the trash keeps its fields; deleting it permanently removes them.
The editor for an event, with the permalink line under the title and the Event types and Event details panels in the sidebar
Editing an event.
  1. The permalink line: the event's address, /events/summer-meetup.
  2. Edit changes the slug in place.
  3. View opens the published page (Preview for a draft).
  4. The app's own taxonomy, Event types, as a panel of checkboxes.
  5. The Event details field group, checked against its schema when saved.
  6. Stream link appears only while Online event is ticked (show_if).

In the editor

  • Admin list. Each type has its own list, with search, filters, Quick Edit and bulk actions. Its columns follow what the type supports and the taxonomies it uses.
  • Panels are the ones the type uses, plus its taxonomies and its field groups. A field with show_if appears only when its condition holds.
  • Permalink line. Under the title, the editor shows the item's address. Edit changes the slug in place (it stays in step with the Slug field in the sidebar), and Preview / View opens the page. This works for posts and pages too.
  • Preview saves a draft first when there are unsaved changes, or when the post has never been saved, then opens the preview in a new tab. On a phone, Preview is also in the ⋮ menu.
The Events list in the admin, with Events and its submenu in the sidebar
Each type has its own list.
  1. Events in the sidebar, with its taxonomies (Event types) underneath.
  2. The list shows events only, with columns for what the type supports and its taxonomies.
  3. New Event opens the editor for a new item.
Two phone screens of the editor: editing the slug under the title, and the menu with Preview
On a phone.
  1. The slug is edited in place, under the title.
  2. Preview on the permalink line saves a draft first when needed.
  3. The ⋮ menu.
  4. Preview is in the menu too.

Editor scripts can call BasehimEditor.saveDraft() (it resolves with the saved post) and BasehimEditor.preview().

In themes

The most specific template the theme has is used:

PageTemplates, in order
An itemsingle-{type}.php, then single.php
A type's archivearchive-{type}.php, then archive.php
A term of an app's taxonomytaxonomy-{key}.php, then archive.php

Templates get $post, $fields (the post's public fields) and $post_type (the type's definition). Archives also get $posts, $meta, $archive_type ('post_type' or 'taxonomy') and $taxonomy.

HelperReturns
bh_field('venue', $post, $default)One field's value. $post defaults to the post being shown
bh_fields($post)Every public field, key => value
bh_field_rows($post)Label and display text for each field, ready for a details list
bh_url($post)The item's address ('' when it has none)
bh_query(['type' => 'event', 'limit' => 3])Published items, newest first, with their fields loaded. Also order, page, author, search, and term => ['taxonomy' => 'event-type', 'slug' => 'workshops']
bh_terms($post, 'event-type')The post's terms in one taxonomy, with name, slug and url
<?php foreach (bh_query(['type' => 'event', 'limit' => 3]) as $ev): ?>
    <a href="<?= $e(bh_url($ev)) ?>"><?= $e($ev['title']) ?></a>
    <?= $e(bh_date(bh_field('starts_at', $ev))) ?> · <?= $e(bh_field('venue', $ev)) ?>
<?php endforeach; ?>

The default theme prints bh_field_rows() as a details list on item pages.

An event's page in the default theme, with its event types and a details list of its fields
An event's page in the default theme.
  1. The event's types, from its Event types taxonomy (bh_terms()).
  2. The public fields as a details list (bh_field_rows()).

In apps

$events = $this->api()->content('event');
$id = $events->create(['title' => 'Summer meetup', 'status' => 'published']);
$errors = $events->saveFields($id, ['venue' => 'Hall 2', 'seats' => 80]);   // [] when saved
$values = $events->fields($id);                                            // private fields included

saveFields() writes only the fields it is given; the others keep their values. Reading needs the posts.read permission and writing posts.write.

REST API and MCP

  • GET /api/v1/content/{type} and /api/v1/content/{type}/{slug} return the published items of a public type, each with url and its public fields. These endpoints are read-only.
  • GET /api/v1/posts/{slug} and /pages/{slug} include fields.
  • The MCP tools get_post and get_page include fields.

Limits

  • Field values have no revision history; core has no revisions yet.
  • Post and media fields are entered as IDs. Media fields have a Choose… button that opens the media library.
  • Required fields warn on publish rather than blocking it.
  • Field values are not searched by site search.

Discussion

Leave a comment