Skip to content

Theme development

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

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'.

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.
  • 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.

Keep building

More guides, or jump straight into the API reference.

Discussion

Leave a comment