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
Theme development
A theme decides what the public site looks like. Core loads the content and works out which template to use. Your template is plain PHP that turns the data into HTML. You don't write controllers, routes or SQL. Themes are deliberately limited to presentation; when you need behaviour, write an app alongside the theme.
Want to build one now? Follow Build a theme from scratch, then come back here for the details.
Folder layout
content/themes/my-theme/
├── theme.json required: the manifest
├── templates/
│ ├── index.php home page and paginated post lists
│ ├── single.php one post
│ ├── page.php one page
│ ├── archive.php category, tag and author archives
│ ├── search.php search results
│ ├── 404.php not found
│ ├── app.php optional: the layout for apps' pages
│ ├── apps/ optional: your versions of apps' templates
│ └── partials/ header.php, footer.php, anything shared
├── assets/ css, js, images, fonts (served to browsers)
├── widgets.php optional: widgets the theme provides
└── boot.php optional: filters, run once per request
Only theme.json is strictly required, but ship all six templates. A missing template falls back to 404.php, not to index.php. A theme without archive.php therefore shows "not found" on every category page.
The manifest: theme.json
{
"name": "My Theme",
"slug": "my-theme",
"version": "1.0.0",
"author": "Your Name",
"description": "One sentence about what this theme is for.",
"requires": { "basehim": ">=1.2.45" },
"menu_locations": {
"primary": "Main menu",
"footer": "Footer links"
},
"widget_areas": {
"sidebar": { "name": "Sidebar", "description": "Beside posts and pages." }
},
"customizer": {
"colors": {
"label": "Colours",
"options": {
"accent": { "type": "color", "label": "Accent", "default": "#2563eb" }
}
}
}
}
| Key | Required | Notes |
|---|---|---|
name, slug, version | yes | slug must match the folder name |
description, author | no | Shown under Appearance → Themes |
requires | no | e.g. {"basehim": ">=1.2.45"} |
menu_locations | no | Places a site owner can assign menus to |
widget_areas | no | Regions that hold widgets; a string label or an object with wrapper markup |
customizer | no | Options shown in Appearance → Customize; see Customizer, menus and widgets |
seo | no | Hands SEO tag groups to the theme; rarely needed, see SEO, dates and images |
Templates
Core picks the template from the URL:
| URL | Template | Main data |
|---|---|---|
/ and /?page=2 | index.php | $posts, $meta |
| a post | single.php | $post, $terms, $comments, $comments_count, $comments_open |
a page (/about) | page.php | $post |
/category/{slug}, /tag/{slug} | archive.php | $posts, $meta, $term, $archive_type |
/author/{slug} | archive.php | $posts, $meta, $author, $archive_type = 'author' |
/search?q=… | search.php | $posts, $meta, $query |
a page an app serves (/shop) | app.php, else page.php | $content, $title; see App pages |
| anything else | 404.php | the common variables only |
Every template also gets the common variables: $base, $site_title, $tagline, $logo_url, $favicon_url, $footer_text, $primary_menu, $footer_menu, $seo and $current_url. The full list is in the Theme reference.
A minimal single.php:
<?php $partial('header'); ?>
<article>
<h1><?= htmlspecialchars($post['title']) ?></h1>
<p><?= bh_time_tag($post['published_at'] ?? $post['created_at']) ?></p>
<?= $post['content'] ?>
</article>
<?= bh_comment_form($post) ?>
<?php $partial('footer'); ?>
$post['content'] arrives as finished HTML, whatever format it was written in (blocks, Markdown or HTML). Print it as is. Every other value is plain data, so escape it with htmlspecialchars().
Partials
$partial('name', $extra) includes templates/partials/name.php. The partial sees all of the template's data, plus anything in $extra:
<?php foreach ($posts as $post): ?>
<?php $partial('card', ['post' => $post]); ?>
<?php endforeach; ?>
Three rules worth knowing:
- Partials can't include partials.
$partialexists only in the top-level template. Keep partials flat. - Avoid the names
$name,$dataand$pathin$extra. Those are used internally, so values passed under them are ignored. - A partial that throws costs only that partial. The error is logged and the rest of the page still renders.
$this inside a template is the theme service, not a view engine. There is no $this->include().
The head and the footer
Every theme must call two functions:
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($seo['title'] ?? $site_title) ?></title>
<link rel="stylesheet" href="<?= theme_asset('my-theme.css') ?>">
<?= bh_head() ?>
</head>
<body class="<?= bh_body_class('my-theme') ?>">
…
<?= bh_footer() ?>
</body>
bh_head() prints the Customizer's CSS variables, the site's custom CSS, AI discovery links, and whatever apps add to the head. bh_footer() prints deferred scripts and whatever apps add before </body>. Leave either one out and every app that adds analytics, a consent banner or a widget script silently does nothing on your theme.
Don't print SEO tags (description, canonical, robots, Open Graph, Twitter cards, JSON-LD). Core adds them to every page itself, and removes copies a theme prints. The <title> above is a fallback; core sets its text.
App pages
Since 1.2.72, apps can serve their own public pages (a shop, a pricing page, a members area) and draw them inside your theme, with your header, footer and menus. See Public routes for apps. The app renders its own content; your theme supplies the frame:
- Without
app.php, core uses yourpage.php, with the app's page as$post(its title, and its HTML ascontent). Every theme already works this way. - With
templates/app.php, you get a layout just for apps' pages:$content(finished HTML, print it as is),$title(escape it), plus the common variables. Use it when apps' pages need a wider column, or no page heading. - To restyle one app's page, copy its template to
templates/apps/{app-slug}/{template}.php. Core uses your copy instead of the app's. It receives the same data, and$e()for escaping.
<?php // templates/app.php ?>
<?php $partial('header'); ?>
<main class="app-page">
<h1><?= htmlspecialchars($title) ?></h1>
<?= $content ?>
</main>
<?php $partial('footer'); ?>
Assets
Put CSS, JavaScript, images and fonts in assets/. That is the only theme folder browsers can reach. Link to files with theme_asset(), which adds the install's base path and a version string:
<link rel="stylesheet" href="<?= theme_asset('my-theme.css') ?>">
<script src="<?= theme_asset('menu.js') ?>" defer></script>
<img src="<?= theme_asset('logo.svg', false) ?>" alt=""> <!-- false: no ?v= -->
The version string is the core version, so browsers fetch your files again after a core update. If you change CSS between core updates, add your own query string, e.g. theme_asset('my-theme.css') . '&t=2'.
Colours: the palette and dark mode (1.3.0)
Basehim has ten named colours (red, orange, yellow, green, blue, pink, purple, brown, black, white) and four statuses (success is green, warning orange, danger red, info blue), with ready-made classes like Bootstrap's. Apps use the same ones in the admin, so a theme that uses them looks like part of the same system. Link the palette's stylesheet (1.5 KB gzipped; the default theme does):
<link rel="stylesheet" href="<?= $base ?>/admin/assets/css/colors.css?v=<?= urlencode(BASEHIM_VERSION) ?>">
<span class="bh-text-bg-success">In stock</span>
<div class="bh-bg-warning-subtle bh-text-warning">Only 3 left</div>
<a class="bh-text-purple" href="…">Members only</a>
Classes: .bh-bg-{name} (the fill), .bh-text-bg-{name} (the fill with readable text), .bh-bg-{name}-subtle (a tint), .bh-text-{name} and .bh-border-{name} / .bh-border-{name}-subtle. For your own CSS, the variables hold r g b channels: color: rgb(var(--bh-success-text)).
Dark mode. The palette's variables are light on :root and dark under <html data-theme="dark">, every combination readable (4.5:1) in both. A theme with a dark mode sets that attribute and the palette follows.
Note: Your Customizer's
accentoption still arrives as--bh-accent, a normal colour (see Customizer options). The palette doesn't define it, and the admin's own accent variables exist only in the admin, so the two never mix.
Links
Build every internal link so it works in a subfolder install:
<a href="<?= $base ?>/">Home</a>
<a href="<?= $base . htmlspecialchars(\App\Core\Helpers::postUrl($post)) ?>">Read more</a>
<a href="<?= htmlspecialchars(link_to('/category/' . $term['slug'])) ?>">…</a>
Helpers::postUrl() follows the site's permalink setting, so a post links to /posts/hello, /hello or /news/hello as configured. Never assemble post URLs by hand.
Pagination
List templates get $meta:
['page' => 2, 'per_page' => 10, 'total' => 57, 'last_page' => 6]
Note: There is no
$paginationvariable. A theme that looks for one shows the first page only, with no error.
Page links use ?page=N, never /page/N (that path belongs to static pages). Keep the rest of the query string so search results paginate correctly:
<?php
$current = max(1, (int) ($meta['page'] ?? 1));
$last = max(1, (int) ($meta['last_page'] ?? 1));
if ($last < 2) return;
$path = strtok((string) ($_SERVER['REQUEST_URI'] ?? '/'), '?');
$url = function (int $n) use ($path): string {
$q = $_GET;
if ($n <= 1) unset($q['page']); else $q['page'] = $n;
$s = http_build_query($q);
return htmlspecialchars($path . ($s !== '' ? '?' . $s : ''), ENT_QUOTES);
};
?>
<nav aria-label="Pages">
<?php if ($current > 1): ?><a href="<?= $url($current - 1) ?>" rel="prev">Newer</a><?php endif; ?>
<span>Page <?= $current ?> of <?= $last ?></span>
<?php if ($current < $last): ?><a href="<?= $url($current + 1) ?>" rel="next">Older</a><?php endif; ?>
</nav>
When something breaks
Themes are often edited on live servers, so core contains failures:
- A template that throws shows administrators the file and line, and everyone else a short message. The site stays up.
- A partial, widget or
boot.phpthat throws is logged and skipped. - A malformed Customizer option is dropped; the rest of the screen works.
Errors go to storage/logs/basehim-YYYY-MM-DD.log.
Before you ship
- Render every template: home, page 2, a post, a page, a category, a tag, an author, a search with results, a search without, and a missing URL.
- If an app with public pages is installed, open one of its pages too.
- Check that
bh_head()andbh_footer()are both called. - View the source of a post: there should be one set of SEO tags, under
<!-- SEO (Basehim) -->. - Change the timezone in Settings → General. Every date should move. Any that doesn't is a
date(…, strtotime(…))left over (usebh_date()). - Try every Customizer option, and look at the site below 1000px wide.
- Post a comment signed out and signed in.
Packaging
ZIP the theme folder itself, so the archive contains my-theme/theme.json. Install it under Appearance → Themes → Upload, or publish it through the marketplace.
Leave a comment