Skip to content

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.vue
text
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 shortcode

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

KeyMeaning
extra.laravel.themesTheme name → base theme. Required for a packaged theme. Must not be empty ({} is dropped by Laravel's package manifest).
extra.kitloom.theme.nameDisplay name on the themes screen. Defaults to the theme name.
extra.kitloom.theme.migrationsA path or a list of paths, relative to the theme directory. Default: database/migrations.
description, version, authorsShown 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 @vite at the theme's build directory, public/build-{Theme};
  • adds the theme's migrations to php artisan migrate;
  • puts the theme's resources/views in 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:

KeyContentsAdded by
contentThe post, term or author with its fieldskitloom/content
metaHeaderTitle, description, robots, canonical, Open Graphkitloom/seo
breadcrumbsBreadcrumb trailkitloom/seo
menus, menuLocationsMenus by slug — {name, items}, items as a tree with id, title, url, target, classes, children… — and location => menu slugkitloom/navigation
widgetsSidebar widgets by sidebarkitloom/content
shortcodesData of shortcodes used on the pagekitloom/shortcodes
viewcomponent, modelType, postType, taxonomy, layoutkitloom/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.