Appearance
Writing a Theme
Introduction
A theme is a directory with a composer.json, a service provider and its templates or components. It can live in your application's themes/ directory or be published as a Composer package — the structure is the same.
The quickest start is to copy one of the bundled themes — kitloom/portfolio-theme (Vue) or kitloom/blade-theme (Blade) — and rename it. kitloom/game-reviews-theme (Vue) shows everything a theme can add on top of the platform: migrations, a config, shortcodes, its own models, extra page props and an API — its README maps each one to the code.
Directory structure
text
my-theme/
├── composer.json
├── screenshot.jpg # 800×600, shown on Appearance → Themes
├── src/
│ └── ThemeServiceProvider.php
├── database/
│ └── migrations/ # optional
└── resources/
├── app.js # Vite entry — its presence makes it a "built" theme
├── ssr.js # optional, server-side rendering entry
├── css/app.css
├── images/ fonts/ # copied to public/{images,fonts}/{Theme}
├── views/
│ └── app.blade.php # Inertia root template
└── js/
├── Layouts/
├── Components/
└── Pages/
├── Pages/Page/Index.vue
├── Pages/Post/Index.vue
├── Pages/Author/Index.vue
├── Pages/404/Index.vue
├── Category/Index.vue
└── Search/Index.vuetext
my-theme/
├── composer.json
├── screenshot.jpg
├── src/
│ └── ThemeServiceProvider.php
└── resources/
└── views/
├── layouts/app.blade.php
├── pages/
│ ├── page/index.blade.php
│ ├── post/index.blade.php
│ ├── author/index.blade.php
│ └── 404/index.blade.php
├── category/index.blade.php
├── partials/
└── shortcodes/ # one template per shortcodecomposer.json
The theme declares itself in extra.laravel.themes — theme name → base theme. A standalone theme is its own base:
json
{
"name": "acme/my-theme",
"description": "The Acme site theme.",
"version": "1.0.0",
"authors": [{ "name": "Acme" }],
"license": "MIT",
"require": {
"php": "^8.2",
"kitloom/themes": "^1.0",
"kitloom/inertia": "^1.0",
"tightenco/ziggy": "^2.0"
},
"autoload": {
"psr-4": { "Acme\\MyTheme\\": "src/" }
},
"extra": {
"laravel": {
"providers": ["Acme\\MyTheme\\ThemeServiceProvider"],
"themes": {
"MyTheme": "MyTheme"
}
},
"kitloom": {
"theme": {
"name": "Acme",
"migrations": "database/migrations"
}
}
}
}The theme requires the engine it renders with — kitloom/inertia or kitloom/blade — so an application gets the engine by installing the theme, never on its own. Ziggy is optional in kitloom/inertia: a theme whose JavaScript imports ziggy-js requires tightenco/ziggy itself, as above. Without Ziggy the shared ziggy prop still carries url — the site address, without a trailing slash, the same form Ziggy gives — and location, so a theme that only needs the site URL works either way.
| Key | Meaning |
|---|---|
extra.laravel.themes | Theme name → base theme. Required for a packaged theme. Must not be empty ({} is dropped by Laravel's package manifest). |
extra.kitloom.theme.name | Display name on the themes screen. Defaults to the theme name. |
extra.kitloom.theme.migrations | A path or a list of paths, relative to the theme directory. Default: database/migrations. |
description, version, authors | Shown on the theme card, like a WordPress style.css header. |
The screenshot is screenshot.png, .jpg, .jpeg or .webp in the theme root (up to 2 MB).
After changing extra
Laravel caches package declarations in bootstrap/cache/packages.php. After editing a theme's extra, run composer update acme/my-theme (or php artisan package:discover).
The service provider
Extend Kitloom\Themes\ThemeServiceProvider. All themes are installed at once, but the base provider only runs your code when your theme — or a theme inheriting from it — is active:
php
<?php
namespace Acme\MyTheme;
use Kitloom\Themes\ThemeServiceProvider as BaseThemeServiceProvider;
class ThemeServiceProvider extends BaseThemeServiceProvider
{
/** `inertia` (kitloom/inertia) or `blade` (kitloom/blade). */
protected string $engine = 'inertia';
protected function getSupportedThemes(): array
{
return ['MyTheme'];
}
protected function registerTheme(): void
{
// Bindings needed only while this theme is active.
}
protected function bootTheme(): void
{
// Routes, view composers, shortcode overrides…
}
}When the theme is active, the base provider also:
- tells kitloom which engine renders pages;
- points
@viteat the theme's build directory,public/build-{Theme}; - adds the theme's migrations to
php artisan migrate; - puts the theme's
resources/viewsin front of the application's views — so a theme can override any view, including a module's.
What a page receives
Both engines get the same page data — the payload modules collected in the page stack. In a Vue theme they are props of the page component; in a Blade theme, variables of the template:
| Key | Contents | Added by |
|---|---|---|
content | The post, term or author with its fields | kitloom/content |
metaHeader | Title, description, robots, canonical, Open Graph | kitloom/seo |
breadcrumbs | Breadcrumb trail | kitloom/seo |
menus, menuLocations | Menus by slug — {name, items}, items as a tree with id, title, url, target, classes, children… — and location => menu slug | kitloom/navigation |
widgets | Sidebar widgets by sidebar | kitloom/content |
shortcodes | Data of shortcodes used on the page | kitloom/shortcodes |
view | component, modelType, postType, taxonomy, layout | kitloom/themes |
Any module may add more — your own pipeline steps included. To see the exact payload of a page, request it as JSON:
bash
curl -H 'Accept: application/json' https://my-site.test/hello-world/Vue themes
The Inertia root template is the theme's resources/views/app.blade.php. If the theme has none, the application's resources/views/app.blade.php is used, then the engine's own.
blade
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@if ($themeEntry = app(\Kitloom\Inertia\ThemeEntry::class)->path())
@vite([$themeEntry])
@endif
@inertiaHead
</head>
<body>
@inertia
</body>
</html>The entry resources/app.js creates the Inertia app as usual. Use the @ alias for the theme's resources/js:
js
import './css/app.css';
import { createSSRApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { ZiggyVue } from 'ziggy-js';
createInertiaApp({
resolve: (name) => {
const pages = import.meta.glob('./js/Pages/**/*.vue', { eager: true });
return pages[`./js/Pages/${name}.vue`];
},
setup({ el, App, props, plugin }) {
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(ZiggyVue)
.mount(el);
},
});Client-side navigation fetches the next page from the same URL with Accept: application/json — no separate API is needed.
Blade themes
Set protected string $engine = 'blade';. The page component name maps to a template name by kebab-casing each segment: Pages/Post/Index → pages.post.index, Category/Genre/Index → category.genre.index. When even the common template is missing, the engine renders a minimal fallback page so you can see the data.
Shortcodes in content arrive as placeholders. Expand them with the @shortcodes directive; each shortcode is rendered with resources/views/shortcodes/{tag}.blade.php, recursively:
blade
<article>
<h1>{{ $content['title'] }}</h1>
@shortcodes($content['content'])
</article>Theme migrations
A theme can bring migrations — tables or options it needs. They are known even before the theme is active, thanks to the composer.json declaration:
- Activating the theme on Appearance → Themes runs its pending migrations first. If they fail, the theme is not switched. The card shows N migrations will run on activation.
- Deploying runs the active theme's migrations with the site's
php artisan migrate. - A child theme runs its base theme's migrations first, then its own.