Appearance
Writing a Theme
Introduction
A theme is a Composer package with a service provider and its templates. The provider extends Kitloom\Site\Themes\ThemeServiceProvider; the templates are named by the WordPress template hierarchy (single-book, archive, index) — see Templates.
The quickest start is php artisan kitloom:make-theme: it writes a Vue theme that already follows this page, with its demo and tests. See Generating a Theme. You can also copy a bundled theme and rename it: kitloom/blade-theme for Blade, kitloom/portfolio-theme for Vue, kitloom/quire-theme for React. kitloom/game-reviews-theme uses every mechanism on this page: migrations, a config, its own models, shortcodes, page parts, shared props and a REST namespace.
Directory structure
text
studio-theme/
├── composer.json
├── screenshot.png # shown on Appearance → Themes
├── src/
│ └── ThemeServiceProvider.php
├── database/
│ └── migrations/ # optional: on activation, then with migrate
└── resources/
└── views/
├── layouts/app.blade.php # the root layout: @siteHead, @siteBodyOpen, @siteFooter
├── partials/
├── shortcodes/ # one view per shortcode tag
└── templates/ # by the template hierarchy
├── index.blade.php # required: every page falls back to it
├── single.blade.php
├── page.blade.php
├── archive.blade.php
└── 404.blade.phptext
studio-theme/
├── composer.json
├── screenshot.png
├── src/
│ └── ThemeServiceProvider.php
├── database/
│ └── migrations/
└── resources/
├── app.js # the Vite entry
├── css/app.css
├── views/
│ └── app.blade.php # the Inertia root view: @siteHead, @siteBodyOpen, @siteFooter
└── js/ # `@` in imports
├── Layouts/
├── Components/
└── Templates/ # by the template hierarchy
├── index.vue # required
├── single.vue
├── page.vue
├── archive.vue
└── 404.vueThe provider lives in src/, one level below the theme root: the base class takes the theme's directory from the provider's file. A provider kept elsewhere overrides path().
composer.json
json
{
"name": "acme/studio-theme",
"description": "The Acme studio site.",
"version": "1.0.0",
"authors": [{ "name": "Acme" }],
"license": "MIT",
"require": {
"php": "^8.4",
"kitloom/site": "^1.0",
"kitloom/site-inertia": "^1.0"
},
"autoload": {
"psr-4": { "Acme\\StudioTheme\\": "src/" }
},
"extra": {
"laravel": {
"providers": ["Acme\\StudioTheme\\ThemeServiceProvider"],
"themes": { "Studio": "Studio" }
},
"kitloom": {
"theme": {
"name": "Studio",
"description": "Case studies, a work grid and a contact page.",
"migrations": "database/migrations"
}
}
}
}| Key | Meaning |
|---|---|
extra.laravel.providers | Laravel discovers the theme's provider: composer require is enough. |
extra.laravel.themes | The theme's name as the key. The Vite plugin and build-themes.js find a Vue theme's package by it; a Blade theme may leave it out. |
extra.kitloom.theme.name | The title on the theme's card. Defaults to the theme's name. |
extra.kitloom.theme.description | The card's text. Defaults to the package's description. |
extra.kitloom.theme.migrations | The theme's migrations directory, run on activation and, while the theme is active, by php artisan migrate. Defaults to database/migrations. |
extra.kitloom.theme.demo | The console command that fills an empty site with the theme's demo, studio:demo. kitloom:install offers to run it on a new site (--demo runs it without asking), and the demo server fills its sandboxes with it; the theme need not be active yet. None by default. |
version, authors | Shown on the card. |
A Blade theme requires kitloom/site only; a Vue theme requires kitloom/site-inertia too. Require every module whose parts or classes the theme uses: on the Modules screen, a theme cannot be activated while one of them is switched off.
The card's image is screenshot.png, .jpg, .jpeg or .webp at the theme root, as in WordPress.
The service provider
php
<?php
declare(strict_types=1);
namespace Acme\StudioTheme;
use Kitloom\Navigation\Parts\Locations;
use Kitloom\Navigation\Parts\Menus;
use Kitloom\Site\Page\Parts\Breadcrumbs;
use Kitloom\Site\Page\Parts\Content;
use Kitloom\Site\Page\Parts\Posts;
use Kitloom\Site\Themes\ThemeServiceProvider as BaseThemeServiceProvider;
class ThemeServiceProvider extends BaseThemeServiceProvider
{
protected string $theme = 'Studio';
protected string $engine = 'inertia'; // or 'blade', the default
protected array $parts = [
'*' => [Content::class, Breadcrumbs::class, Menus::class, Locations::class],
'archive' => [Posts::class],
'index' => [Posts::class],
];
protected array $deferredParts = [];
protected function registerTheme(): void
{
// Bindings, whichever theme is active.
}
protected function bootTheme(): void
{
// Only while this theme is active.
}
}| Property / method | Meaning |
|---|---|
$theme | The theme's name, as the kitloom_theme option and APP_THEME hold it. |
$engine | blade or inertia. |
$parts | Vue themes: the page parts each template gets as props. '*' — every template; a template name — that one as well. Listing nothing gives every template the parts a JSON page has. |
$deferredParts | Vue themes: parts sent as Inertia deferred props, after the page has shown — what is below the first screen and of no use to search engines. |
registerTheme() | Runs whichever theme is active, so what it binds must do nothing by itself. |
bootTheme() | Runs only while the theme is active, as WordPress loads the active theme's functions.php: page parts, shortcodes, menu locations, listeners, a REST namespace. |
A Blade theme asks for parts itself, from the template, so it lists none.
Locations and zones are the admin's
A theme registers only the menu locations and widget zones it needs on a fresh site. Any other is added on Menu Locations or Appearance → Widgets and drawn by id, with no change to the theme — see Menus & Widgets.
What a theme adds in bootTheme()
Everything a theme adds to the site goes into bootTheme(), so it is gone when another theme is active:
php
use Inertia\Inertia;
use Kitloom\Navigation\MenuLocations;
use Kitloom\Settings\Facades\Settings;
use Kitloom\Shortcodes\Shortcodes;
use Kitloom\Site\Page\Page;
use Kitloom\Site\Page\PageParts;
use Kitloom\WpSchema\Widgets\SidebarRegistry;
protected function bootTheme(): void
{
// A page part of the theme's own (see Templates).
$this->app->make(PageParts::class)->provide(
ProjectDetails::class,
fn (Page $page): ProjectDetails => ProjectDetails::of($page),
json: static fn (Page $page): bool => $page->result->isSingle() && $page->result->postType === 'project',
);
// The menu locations and widget zones the theme needs on a fresh site, as
// register_nav_menus() and register_sidebar(). Others are added in the admin.
$this->app->make(MenuLocations::class)->register(['studio' => 'Studio header']);
$this->app->make(SidebarRegistry::class)->register(['studio-footer' => 'Footer']);
// Shortcodes of the theme.
$this->app->make(Shortcodes::class)->addShortcode('portfolio', PortfolioShortcode::class);
// Props every page of a Vue theme gets; closures are called only when Inertia renders.
Inertia::share('studio', fn (): array => ['email' => Settings::get('admin_email')]);
}A Blade theme gives its layout data the Laravel way, with a view composer: View::composer(['layouts.*', 'templates.*'], …).
Post types and taxonomies of a theme are a Kitloom\Site\Contracts\ContentTypeProvider tagged ContentTypes::TAG in registerTheme(). Types are collected whenever something first needs them, so the provider asks Themes::isActive() itself and yields nothing while another theme is active — as kitloom/portfolio-theme's PortfolioTypes does. See Extending.
A theme in a folder
A theme can also be a folder of your application: every folder under themes/ (or kitloom-site.theme.path) is a theme of that name. A folder with resources/js/Templates is a Vue theme, any other a Blade theme. It needs no provider and no composer.json; its card shows its folder name. Without a provider it lists no parts, so a Vue template gets the parts a JSON page has. A folder whose composer.json names a parent (extra.kitloom.theme.parent) is a child theme: it is shown by its parent's engine and takes its parts, so it needs no provider either.
What a template gets
Blade
A Blade template gets $page, a Kitloom\Site\Page\Page:
$page->part(Content::class) | A page part, worked out the first time it is asked for. Throws MissingPart when no installed module provides it. |
$page->has(Head::class) | Whether a module provides the part — for parts of modules the site may not have. |
$page->result | The main query's result, with WordPress's conditional tags: isSingle(), isPage(), isFrontPage(), isSearch(), isNotFound()… |
$page->template | The template being shown: $page->template->name. |
$page->request | The request. |
$page->url($path) | A site path as a full address. |
blade
@extends('layouts.app')
@section('content')
@php($content = $page->part(\Kitloom\Site\Page\Parts\Content::class))
<article>
<h1>{{ $content->title }}</h1>
@shortcodes($content->content)
</article>
@endsectionThe theme's other views are found by their plain names — @extends('layouts.app'), @include('partials.menu') — and under theme::; a theme's view wins over the application's view of the same name.
Vue
A Vue template is an Inertia page. Its props:
| Prop | Value |
|---|---|
kind | The page kind: single, page, home, category, tag, taxonomy, author, date, post-type-archive, search, not-found. |
template | The template's name: single-project. |
url | The page's full address. |
locale | The application's locale. |
ziggy | The application's routes for route() when tightenco/ziggy is installed; otherwise { url }, the site address. |
| the parts | Each part the theme lists for the template, under its key: content, posts, menus… Parts of modules the site does not have are left out. |
Props a theme shares with Inertia::share() come next to these.
vue
<script setup>
import SiteLayout from '@/Layouts/SiteLayout.vue';
defineProps({
content: { type: Object, default: () => ({}) },
});
</script>
<template>
<SiteLayout>
<h1>{{ content.title }}</h1>
<div v-html="content.content" />
</SiteLayout>
</template>The parts and their keys are listed in Templates.
The props are typed. Write the theme's templates in TypeScript (<script setup lang="ts">) and the build does the rest: before the theme is built, Vite runs php artisan kitloom:schema --types=… into the theme's resources/js/kitloom.d.ts, from the schemas of the parts this site has installed (set PHP when the binary is not php). Keep that file out of git. PageProps<K> is the page with the parts the theme lists for a template:
vue
<script setup lang="ts">
import type { PageProps } from '../kitloom';
defineProps<PageProps<'content' | 'masthead'>>();
</script>The parts between the brackets are the ones the template always has; the parts of a module (menus, comments, head) stay optional, as a site may run without the module. Import the file by a relative path: Vue's compiler reads the type to make the runtime props. Add a tsconfig.json to the theme and npm run build:themes type-checks it with vue-tsc after its build — a template that reads a key no part gives fails there, not on the site. The Journal theme is written this way.
Every part has an interface named after its key (Content, Posts, MenuLocations), and the records a list gives are Entry, with Image and Reference. See Headless & APIs → The schema of a page.
React
A React theme has the same server side — the provider, $engine = 'inertia', the parts — and its templates are React components with the same props: resources/js/Templates/single-product.tsx, on @inertiajs/react 3. Its entry is resources/app.tsx (or .ts, .jsx: the one the theme has when it has no app.js):
tsx
const templates = import.meta.glob<{ default: ComponentType }>('./js/Templates/**/*.tsx', { eager: true });
createInertiaApp({
resolve: (name) => (templates[`./js/Templates/${name}.tsx`] ?? templates['./js/Templates/index.tsx']).default,
setup: ({ el, App, props }) => createRoot(el).render(<App {...props} />),
});The application's Vite config has react({ include: /\.[jt]sx$/ }) (@vitejs/plugin-react) beside vue() — each takes only its own files — and the theme's root view puts @viteReactRefresh before @vite for the dev server. The generated kitloom.d.ts types the props as for Vue (PageProps<K>), and a theme with a tsconfig.json is type-checked with tsc after its build. The Quire theme is written this way.
The root layout
Blade
The theme's root layout is its own: usually resources/views/layouts/app.blade.php, extended by every template.
Vue
The Inertia root view is the theme's resources/views/app.blade.php. It gets $themeEntry, the theme's Vite entry as @vite takes it — the build of the active theme is used:
blade
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
@vite([$themeEntry])
@inertiaHead
@siteHead
</head>
<body>
@siteBodyOpen
@inertia
@siteFooter
</body>
</html>resources/app.js resolves the template Inertia names among the theme's templates:
js
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
const templates = import.meta.glob('./js/Templates/**/*.vue', { eager: true });
createInertiaApp({
resolve: (name) => (templates[`./js/Templates/${name}.vue`] ?? templates['./js/Templates/index.vue']).default,
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) }).use(plugin).mount(el);
},
});The server names only a template the build has, so the fallback to index is a safety net. With Inertia 3 the page comes in a <script type="application/json">, not a data-page attribute. Head tags the root view prints for crawlers can carry data-inertia="{key}", so a <Head> with the same head-key replaces them instead of adding a second set — the bundled Vue themes do this for the title, description, robots and canonical tags.
Places for modules' scripts
WordPress's wp_head(), wp_body_open() and wp_footer() are three Blade directives. A theme must put them in its root layout, or modules cannot add their markup — analytics, a tag manager, a consent banner, SEO meta, a form's anti-spam script:
| Directive | Where |
|---|---|
@siteHead | right before </head> |
@siteBodyOpen | right after <body> |
@siteFooter | right before </body> |
A Blade theme puts them in its layout, a Vue theme in app.blade.php. In a Vue theme they are printed on the first load only; a script that counts page views listens to Inertia's navigate event.
Shortcodes
kitloom/shortcodes leaves an anchor, <div data-shortcode="…"></div>, in the text where a shortcode was, and puts its data into the page's shortcodes part. See Shortcodes.
A Blade theme draws them with the @shortcodes directive:
blade
@shortcodes($content->content)Each anchor becomes the theme's view shortcodes/{tag} — resources/views/shortcodes/contact-form-7.blade.php — with the shortcode's data in $shortcode (arguments, shortcode, content and what the shortcode's data() returned). A shortcode the theme has no view for leaves nothing behind.
A Vue theme lists ShortcodeData::class in its parts and draws each anchor itself from the shortcodes prop, by the anchor's key — the bundled themes split the HTML on the anchors in a RichContent.vue component.
A sign-in form
kitloom/site answers the sign-in form; the theme draws it, as wp-login.php serves a WordPress site's visitors:
blade
<form method="POST" action="{{ route('login') }}">
@csrf
<input name="login" value="{{ old('login') }}" autocomplete="username">
<input name="password" type="password" autocomplete="current-password">
<label><input name="remember" type="checkbox" value="1"> Remember me</label>
@error('login') <p>{{ $message }}</p> @enderror
<button>Sign in</button>
</form>
<form method="POST" action="{{ route('logout') }}">@csrf <button>Sign out</button></form>login takes the login name or the email. Without Accept: application/json the answer is a redirect with the errors in the session; with it, {"redirect": "…"}, a challenge when a module asks for one more step (kitloom/two-factor), or 422 with the error under login.
GET /login stays a site address: it is where Laravel's auth middleware sends guests, so give the site a page with the slug login and the theme a page-login template with the form. The routes are turned off with kitloom-site.auth.routes.
A REST namespace of the theme
A theme whose pages call the server — a vote, a live search — mounts a namespace under the site's REST root, /wp-json/. It implements Kitloom\Site\Contracts\RestNamespace and is tagged in bootTheme(), so its routes exist only while the theme is active. kitloom/game-reviews-theme:
php
use Illuminate\Routing\Router;
use Kitloom\Site\Contracts\RestNamespace;
final class GameReviewsNamespace implements RestNamespace
{
public function __construct(private readonly int $voteThrottle = 10) {}
public function name(): string
{
return 'game-reviews/v1';
}
public function register(Router $router): void
{
// `web` for the session and CSRF: the page posts a vote with the XSRF cookie.
// Each throttle counts under its route's name, so searching does not use up the votes.
$router->middleware('web')->name('game-reviews.')->group(function (Router $router): void {
$router->get('search', [GameReviewsApiController::class, 'search'])
->middleware('throttle:60,1,game-reviews.search')
->name('search');
$router->get('{review}/rating', [GameReviewsApiController::class, 'rating'])
->whereNumber('review')
->name('rating.show');
$router->post('{review}/rating', [GameReviewsApiController::class, 'rate'])
->whereNumber('review')
->middleware('throttle:'.max(1, $this->voteThrottle).',1,game-reviews.rating.store')
->name('rating.store');
});
}
public function routes(): array
{
return ['/search' => ['GET'], '/(?P<review>[\d]+)/rating' => ['GET', 'POST']];
}
}
// ThemeServiceProvider::bootTheme()
$this->app->tag([GameReviewsNamespace::class], RestNamespace::TAG);The routes are mounted once every provider has booted, at /wp-json/game-reviews/v1/search. Controllers, not closures; errors reach the client as WordPress's JSON. See Headless & APIs.
Settings for the owner
What the owner may change without code — the accent colour, the logo, the footer's line — the provider declares in settings(); the owner changes it on Appearance → Customize, and the theme reads it as CSS variables, through the Theme facade in Blade or the theme part in Vue. See Theme Settings (Customize).
Migrations
A theme's migrations live in database/migrations (or the directory extra.kitloom.theme.migrations names). The Themes screen shows how many are pending and runs them when the theme is activated; if they fail, the theme is not switched.
From then on php artisan migrate runs the active theme's migrations with the site's: a new migration the theme brings in a deploy runs there, and a new environment whose kitloom_theme option or APP_THEME names the theme gets its tables from migrate. Themes that are not active keep theirs until they are activated.
A theme's down() should remove only what it defined, never the content editors created with it.
Testing
Kitloom\Site\Site renders a page without a request, as a visitor (or a given user) would get it:
php
use Kitloom\Site\Site;
$rendered = app(Site::class)->render('/projects/harbour-identity/');
$this->assertSame('single-project', $rendered->template);
$this->assertSame(200, $rendered->status);
$this->assertStringContainsString('Harbour identity', $rendered->body);See Testing.