Skip to content
Developer docs

Theme development

Updated 10 min read

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" }
      }
    }
  }
}
KeyRequiredNotes
name, slug, versionyesslug must match the folder name
description, authornoShown under Appearance → Themes
requiresnoe.g. {"basehim": ">=1.2.45"}
menu_locationsnoPlaces a site owner can assign menus to
widget_areasnoRegions that hold widgets; a string label or an object with wrapper markup
customizernoOptions shown in Appearance → Customize; see Customizer, menus and widgets
seonoHands SEO tag groups to the theme; rarely needed, see SEO, dates and images

Templates

Core picks the template from the URL:

URLTemplateMain data
/ and /?page=2index.php$posts, $meta
a postsingle.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 else404.phpthe 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. $partial exists only in the top-level template. Keep partials flat.
  • Avoid the names $name, $data and $path in $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().

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 your page.php, with the app's page as $post (its title, and its HTML as content). 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 accent option 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.

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 $pagination variable. 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.php that 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() and bh_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 (use bh_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.

Discussion

Leave a comment