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.jsonhooks. - 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.
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:
| What | Primary | Fallback |
|---|---|---|
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.json | themes/<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).
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"
}
| Key | Required | Meaning |
|---|---|---|
name / slug | Yes | Display name; slug must equal the directory name — it keys view resolution and options. |
description, author, version | Yes | Shown in the admin theme manager. |
is_available / status | Yes | is_available lists the theme; status is the active flag persisted by the manager. |
screenshot | No | [{"primary": "primary.png"}], relative to screenshot/. |
headerHook | Yes | Array (first entry wins). See below. |
footerHook | Yes | Same shape; controls footer scripts/styles and the widget area. |
provider | No | Optional Laravel provider (Themes\<Name>\ThemeServiceProvider) when the theme needs to register hooks or routes of its own. |
scope | No | "presentation" for pure frontend themes. |
headerHook / footerHook fields
| Field | Effect |
|---|---|
loadCoreStyle / loadCoreScript | Also load the default base theme's core CSS/JS on top of yours. |
style | Array 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_style | Same, but rendered only for RTL locales. Convention: <slug>-rtl. |
script | Array of JS file names loaded in the footer hook. |
blade | Array of extra Blade partials to render in the header/footer hook. |
navbarArea / breadcrumbArea | Name of the partial directory under your theme for navbar/breadcrumb. |
widgetArea | Footer 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 rootthemes/trees. - When your partials reference optional module/plugin view namespaces (
product::…,blog::…), guard them withview_exists()so a disabled module cannot throw:
@if(view_exists('product::frontend.components.card'))
@include('product::frontend.components.card')
@endif
Never route() to a module or plugin route from a theme without checking the module is enabled. The theme must render on minimal installs.
Navbar, breadcrumb and widget area
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:
| Part | theme.json key | Directory | Rendered by |
|---|---|---|---|
| Navbar | headerHook[0].navbarArea | headerNavbarArea/<name>.blade.php | tenant/frontend/partials/navbar.blade.php (fires tenant_frontend_navbar after) |
| Breadcrumb | headerHook[0].breadcrumbArea | headerBreadcrumbArea/<name>.blade.php | breadcrumb partial |
| Footer widgets | footerHook[0].widgetArea | footerWidgetArea/<name>.blade.php | tenant/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 thestyleandrtl_stylenames resolve (for examplecourse-main-style.css,course-rtl.css). - Name your main stylesheet
<slug>-main-styleand the RTL variant<slug>-rtl, then list them intheme.json. - Theme JS goes through
footerHook.scriptand lands in the footer (after jQuery). - The
defaultbase theme's core CSS/JS can be layered withloadCoreStyle/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
| Helper | Returns | Purpose in themes |
|---|---|---|
themeView($view, $data) | View | Theme-aware rendering with fallback — controllers use this; themes rarely call it directly. |
getSelectedThemeSlug() | string | Active theme slug — for slug-scoped asset URLs or logic. |
getSelectedThemeData() | object | Active theme's parsed theme.json. |
theme_assets($file, $theme = '') | string URL | URL to a theme asset file. |
render_attachment_preview_for_admin($id) | HTML | Media attachments (shared helper, also usable in theme templates). |
view_exists($view) | bool | Safe 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.
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:
| Location | Hook tag | Purpose |
|---|---|---|
headerNavbarArea/navbar.blade.php top | theme_before_navbar | Inject above the navbar (for example an announcement bar). |
headerNavbarArea/navbar.blade.php bottom | theme_after_navbar | Inject below the navbar. |
footerWidgetArea/widget-area.blade.php top | theme_before_footer_widgets | Inject above the footer widgets. |
footerWidgetArea/widget-area.blade.php bottom | theme_after_footer_widgets | Inject 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
@includeanother theme's views or reach intofallbacksdirectly — that is what thethemeView()fallback is for. - Own your assets. Reference assets via
theme_assets()or yourtheme.jsonlists — never hard-code another theme's CSS URL. - Admin views are off-limits. Themes never render
tenant/admin/*orlandlord/*views. - Plugin views are optional. Any plugin view reference must be guarded with
view_exists(). - Per-theme RTL pair for every style listed in
stylewhen 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 aftertenant_frontend_head/headerhave already fired. Themes must not ship a fullfrontend-master.blade.phpthat bypassestenant/frontend/partials/header.
Activation and compatibility gates
Three layers enforce the theme contract for every production user — no GitHub CI, no manual command:
- Upload gate —
ThemeManageController::store_themerunsThemeCompatibilityChecker(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 thetheme_manager.discovercache. - Activation gate —
OtherSettingsController::update_theme_settingsre-runs the same checker. A theme missing the per-theme hooks or with an invalidtheme.jsoncannot be activated; the UI/API returns422with the exact checker errors. - Discovery gate —
ThemeManagerService::scan()runs the checker on everythemes/{slug}at discovery time (cached 1h). A theme dropped via FTP/SFTP without the upload UI is flagged_incompatiblewith aLog::warning, so the landlord theme list andhook:auditsurface 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
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, optionalprovider, screenshot.headerNavbarArea/navbar.blade.phpplusheaderBreadcrumbArea/andfooterWidgetArea/— 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.cssandcourse-rtl.cssRTL 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.jsonpresent withslugequal to the directory name. - Screenshot at
screenshot/primary.png. - Stylesheets named
<slug>-main-style/<slug>-rtl, listed inheaderHook, files underassets/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 afunction_existsguard. - 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).

