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
│ └── 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 |
| 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.
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'.
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.
- 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