Xgenious/ docs

Theme Development

A theme controls the tenant website frontend — the storefront your customers' customers see. The landlord central site and the admin panels are not themeable; those are fixed application UI.

A theme supplies:

  • Frontend Blade templates resolved through the themes.<slug>.* namespace with automatic fallback to core templates.
  • Layout parts — navbar, breadcrumb and footer widget areas.
  • Assets — CSS (with RTL variants) and JS loaded through theme.json hooks.
  • Metadata — name, author, version and screenshot for the admin theme manager.

Exactly one tenant theme is active at a time per platform, stored in options. The active theme's views override the fallbacks; anything the theme does not ship falls back to core.

The global hook system is documented in Plugin Development and the complete tag list in Hooks Reference.

Note

Theme frontends are Bootstrap-based by design (unlike admin panels, which are Tailwind/Ink). You may use Bootstrap markup, card card-new-styles, las la-* icons and jQuery in theme views. What is dead is assuming admin-only classes; theme CSS is self-contained.

File locations and resolution chain

Per project convention, the repo-root themes/<slug>/ tree is authoritative. Discovery paths exist for both views and metadata:

WhatPrimaryFallback
View resolution (themes.<slug>.frontend.*)Repo root: themes/<slug>/frontend/…, registered via app('view')->getFinder()->addLocation(base_path('..')) in AppServiceProvider::boot()Legacy: core/resources/views/themes/<slug>/…
theme.json discovery (ThemeMetaData::getIndividualThemeData)theme_path($name)/theme.jsonthemes/<slug>/theme.json, then ../themes/<slug>/theme.json

Shipped themes: agency, article-listing, barber-shop, construction, consultancy, course, donation, eCommerce, event, hotel-booking, job-find, newspaper, photography, portfolio, restaurant, software-business, support-ticketing, wedding — plus the default base theme (header/footer/typography always loaded for every theme).

Warning

Keep theme.json present in whichever tree you use. The admin theme manager lists themes by discovering these files, and a missing file makes the theme invisible or breaks the active-theme lookup.

Theme skeleton

themes/<your-theme>/
├── theme.json              // required — identity + asset hooks
├── screenshot/
│   └── primary.png         // shown in the admin theme manager
├── assets/
│   ├── css/<slug>-main-style.css
│   └── css/<slug>-rtl.css  // optional RTL variant
├── frontend/               // page views — resolved as themes.<slug>.frontend.*
│   ├── home.blade.php      // …one file per page type
│   ├── listing.blade.php
│   ├── single.blade.php
│   └── search.blade.php
├── headerNavbarArea/       // navbar partial(s)
│   └── navbar.blade.php
├── headerBreadcrumbArea/   // breadcrumb partial
├── partials/               // reusable partials (cards, rows…)
│   └── course-card.blade.php
└── footerWidgetArea/
    └── widget-area.blade.php

The theme.json contract

A real example — the course theme:

{
    "name": "Course",
    "slug": "course",
    "description": "Course Management Theme",
    "author": "Al Ahsan",
    "version": "1.0.0",
    "is_available": true,
    "status": true,
    "screenshot": [{ "primary": "primary.png" }],
    "headerHook": [{
        "loadCoreStyle": true,
        "style": [],
        "rtl_style": [],
        "script": [],
        "blade": [],
        "navbarArea": "navbar",
        "breadcrumbArea": "breadcrumb"
    }],
    "footerHook": [{
        "loadCoreScript": true,
        "style": [],
        "script": [],
        "blade": [],
        "widgetArea": "widget-area"
    }],
    "id": "course",
    "provider": "Themes\\Course\\ThemeServiceProvider",
    "scope": "presentation"
}
KeyRequiredMeaning
name / slugYesDisplay name; slug must equal the directory name — it keys view resolution and options.
description, author, versionYesShown in the admin theme manager.
is_available / statusYesis_available lists the theme; status is the active flag persisted by the manager.
screenshotNo[{"primary": "primary.png"}], relative to screenshot/.
headerHookYesArray (first entry wins). See below.
footerHookYesSame shape; controls footer scripts/styles and the widget area.
providerNoOptional Laravel provider (Themes\<Name>\ThemeServiceProvider) when the theme needs to register hooks or routes of its own.
scopeNo"presentation" for pure frontend themes.

headerHook / footerHook fields

FieldEffect
loadCoreStyle / loadCoreScriptAlso load the default base theme's core CSS/JS on top of yours.
styleArray of CSS file names (no extension) loaded in the <head>, resolved to assets/tenant/frontend/themes/css/<file>.css. E.g. ["course-main-style"].
rtl_styleSame, but rendered only for RTL locales. Convention: <slug>-rtl.
scriptArray of JS file names loaded in the footer hook.
bladeArray of extra Blade partials to render in the header/footer hook.
navbarArea / breadcrumbAreaName of the partial directory under your theme for navbar/breadcrumb.
widgetAreaFooter widget-area partial directory name.

The tenant header/footer templates emit these through ThemeDataFacade::getHeaderHookCssFiles(), getHeaderHookRtlCssFiles() and the script equivalents — you never hard-code <link> tags yourself.

Views and the fallback chain

Controllers never call view('themes.course.frontend.single') directly — they use the theme-aware helper:

// controllers / plugins render pages like this:
return themeView('course.single', $data);   // note: 'course.single', not 'single'

// Under the hood (ThemeMetaData::renderThemeView / ThemeViewResolverContract):
//   1. try   themes.<active-slug>.frontend.<view>
//   2. else  fallbacks.frontend.<view>       // core fallback templates
//   3. render
  • Fallback is automatic and per-view. Ship only the templates you want to restyle; missing ones render the core fallback. This is why a theme can be as small as a navbar plus CSS.
  • Views are plain Blade. They extend the tenant frontend master chain (tenant.frontend.frontend-page-master) or render standalone pages — follow the patterns in the existing root themes/ trees.
  • When your partials reference optional module/plugin view namespaces (product::…, blog::…), guard them with view_exists() so a disabled module cannot throw:
@if(view_exists('product::frontend.components.card'))
    @include('product::frontend.components.card')
@endif
Warning

Never route() to a module or plugin route from a theme without checking the module is enabled. The theme must render on minimal installs.

The three swappable layout parts are chosen by theme.json and rendered by core partials — no hard-coded includes, the theme's partial wins, otherwise the core one renders:

Parttheme.json keyDirectoryRendered by
NavbarheaderHook[0].navbarAreaheaderNavbarArea/<name>.blade.phptenant/frontend/partials/navbar.blade.php (fires tenant_frontend_navbar after)
BreadcrumbheaderHook[0].breadcrumbAreaheaderBreadcrumbArea/<name>.blade.phpbreadcrumb partial
Footer widgetsfooterHook[0].widgetAreafooterWidgetArea/<name>.blade.phptenant/frontend/partials/widget-area.blade.php (fires tenant_frontend_widget_area after)

You can ship multiple navbar variants (for example navbar.blade.php and navbar-compact.blade.php) and switch by editing theme.json — no code changes.

Assets and stylesheets (including RTL)

  • Theme CSS files are published/served under assets/tenant/frontend/themes/css/ — that is where the style and rtl_style names resolve (for example course-main-style.css, course-rtl.css).
  • Name your main stylesheet <slug>-main-style and the RTL variant <slug>-rtl, then list them in theme.json.
  • Theme JS goes through footerHook.script and lands in the footer (after jQuery).
  • The default base theme's core CSS/JS can be layered with loadCoreStyle / loadCoreScript; your theme's files load after core so you can override.
{
    "headerHook": [{
        "loadCoreStyle": true,
        "style": ["your-theme-main-style"],
        "rtl_style": ["your-theme-rtl"],
        "script": ["your-theme-header"],
        "blade": [],
        "navbarArea": "navbar",
        "breadcrumbArea": "breadcrumb"
    }],
    "footerHook": [{
        "loadCoreScript": true,
        "style": [],
        "script": ["your-theme-main"],
        "blade": [],
        "widgetArea": "widget-area"
    }]
}

Template helpers

HelperReturnsPurpose in themes
themeView($view, $data)ViewTheme-aware rendering with fallback — controllers use this; themes rarely call it directly.
getSelectedThemeSlug()stringActive theme slug — for slug-scoped asset URLs or logic.
getSelectedThemeData()objectActive theme's parsed theme.json.
theme_assets($file, $theme = '')string URLURL to a theme asset file.
render_attachment_preview_for_admin($id)HTMLMedia attachments (shared helper, also usable in theme templates).
view_exists($view)boolSafe optional-namespace check.

Theme hooks

Themes integrate with the same hook system as plugins. There are two directions: consuming hooks (reacting to core) and exposing theme-local hooks (letting plugins target your layout).

Consuming core hooks

A theme may register a ThemeServiceProvider (referenced by provider in theme.json) and consume any hook in the catalogue:

// themes/Course/ThemeServiceProvider.php
namespace Themes\Course;

use Illuminate\Support\ServiceProvider;

class ThemeServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        if (! function_exists('add_filter')) {
            return;
        }

        // Decorate a fallback section
        add_filter('shop_grid_products', function ($html) {
            return $html;  // or replace entirely with your own markup
        }, 10);

        // Inject into the tenant footer
        add_action('tenant_frontend_footer', function () {
            echo '<script src="'.theme_assets('your-theme-analytics.js').'"></script>';
        }, 30);
    }
}
  • Register once at boot — the registry is per-request.
  • Scope guards apply: tenant_frontend_* hooks only fire on tenant websites, which is where themes render.
  • Do not remove another theme's hooks or edit core emitters; consume only.
Warning

The provider field is inert unless the platform boots theme providers. Historically themes/course/theme.json declared a provider that did not exist, and nothing in core booted it. If you rely on a theme provider, verify it is actually loaded in your build; otherwise put hook registration in a plugin that ships with the theme.

Exposing theme-local hooks

Every theme ships four placeholders so a plugin can target a specific theme's navbar and footer without knowing its HTML. They are render_action, no arguments, and return an empty string when nothing listens:

LocationHook tagPurpose
headerNavbarArea/navbar.blade.php toptheme_before_navbarInject above the navbar (for example an announcement bar).
headerNavbarArea/navbar.blade.php bottomtheme_after_navbarInject below the navbar.
footerWidgetArea/widget-area.blade.php toptheme_before_footer_widgetsInject above the footer widgets.
footerWidgetArea/widget-area.blade.php bottomtheme_after_footer_widgetsInject below the footer widgets.

Emit them in your partials:

{{-- headerNavbarArea/navbar.blade.php --}}
{!! render_action('theme_before_navbar') !!}
<nav class="navbar">…your markup…</nav>
{!! render_action('theme_after_navbar') !!}
{{-- footerWidgetArea/widget-area.blade.php --}}
{!! render_action('theme_before_footer_widgets') !!}
<div class="footer-widget-area">…your markup…</div>
{!! render_action('theme_after_footer_widgets') !!}

A plugin then listens on the generic tag (fires for every theme):

add_action('theme_before_navbar', function () {
    echo '<div class="announcement-bar">Free shipping this week</div>';
}, 10);

For theme-scoped hooks, core exposes ThemeHookService::themeTag($slug, 'before_navbar'). Prefer the generic tags — core deliberately does no theme-specific branching.

Theme isolation rules

Enforced by the theme-isolation audit (see docs/architecture-migration/THEME_ISOLATION_AUDIT.md):

  • No cross-theme includes. A theme may not @include another theme's views or reach into fallbacks directly — that is what the themeView() fallback is for.
  • Own your assets. Reference assets via theme_assets() or your theme.json lists — never hard-code another theme's CSS URL.
  • Admin views are off-limits. Themes never render tenant/admin/* or landlord/* views.
  • Plugin views are optional. Any plugin view reference must be guarded with view_exists().
  • Per-theme RTL pair for every style listed in style when the theme targets multilingual sites.
  • Do not re-emit core frontend hooks. Every theme's navbar is included by tenant/frontend/partials/navbar.blade.php, which is included after tenant_frontend_head/header have already fired. Themes must not ship a full frontend-master.blade.php that bypasses tenant/frontend/partials/header.

Activation and compatibility gates

Three layers enforce the theme contract for every production user — no GitHub CI, no manual command:

  1. Upload gate — ThemeManageController::store_theme runs ThemeCompatibilityChecker (core/app/Services/Upload/) on the extracted zip before any file moves. Errors block the upload and are shown in the landlord UI; warnings are displayed but do not block. Uploads write to the authoritative locations (themes/ at repo root) and bust the theme_manager.discover cache.
  2. Activation gate — OtherSettingsController::update_theme_settings re-runs the same checker. A theme missing the per-theme hooks or with an invalid theme.json cannot be activated; the UI/API returns 422 with the exact checker errors.
  3. Discovery gate — ThemeManagerService::scan() runs the checker on every themes/{slug} at discovery time (cached 1h). A theme dropped via FTP/SFTP without the upload UI is flagged _incompatible with a Log::warning, so the landlord theme list and hook:audit surface it. It stays discoverable but is blocked at activation.

For local/CLI pre-flight, the same checks are available as artisan commands:

php artisan theme:check  ../themes/my-theme          # extracted directory
php artisan theme:check  /path/to/my-theme.zip       # zip file
php artisan theme:check  /path/to/my-theme.zip --json

# Hook coverage for the theme's layout partials
php artisan hook:audit --theme=my-theme
Tip

Theme switching is instant — views resolve per-request through the fallback chain, so no cache rebuild is needed. If you edited a theme file and do not see it, clear the Laravel view cache with php artisan view:clear.

Reference: the course theme

The course theme is a compact end-to-end reference:

  • theme.json — full contract usage: loadCoreStyle, navbarArea/breadcrumbArea/widgetArea, optional provider, screenshot.
  • headerNavbarArea/navbar.blade.php plus headerBreadcrumbArea/ and footerWidgetArea/ — all three layout parts, each with the theme-local hook placeholders.
  • frontend/ — listing, single and search views rendering course data through the theme namespace with fallback to core templates.
  • partials/course-card.blade.php — reusable partial pattern.
  • assets/css/ — course-main-style.css and course-rtl.css RTL pair.

Copy this structure as your starting point: duplicate the directory, rename the slug everywhere (directory, theme.json slug/id, CSS file names) and restyle.

Developer checklist

  • theme.json present with slug equal to the directory name.
  • Screenshot at screenshot/primary.png.
  • Stylesheets named <slug>-main-style / <slug>-rtl, listed in headerHook, files under assets/tenant/frontend/themes/css/.
  • All views reachable via themeView() naming; every view you skip falls back cleanly.
  • The four theme-local hook placeholders present in the navbar and widget partials.
  • Optional module/plugin view references guarded with view_exists().
  • No cross-theme includes, no admin views, no hard-coded plugin routes.
  • Hooks registered from boot() with a function_exists guard.
  • Verified with php artisan theme:check, php artisan view:clear, and on a tenant with the theme active and with modules disabled (minimal-install test).
Still stuck?
Our support team is ready to help you get set up.
Get support