Layouts and the page shell
The two-layer layout chain: the base HTML shell, the header/footer chrome, and the Livewire slot short-circuit that makes both page types work.
On this page
- Layouts and the page shell
- The two-section chain
- The base shell — layouts/app.blade.php
- The Livewire slot short-circuit — why it exists
- The chrome — layouts/frontend.blade.php
- How a controller page uses the chain
- The store bridge — full-page components inside your chrome
- Checklist for a correct layout pair
Layouts and the page shell#
Every page a theme renders — a controller page, a Livewire auth screen, a full-page store component — is drawn inside the same HTML document. That document is your layout, and a Salieno theme ships it as two files that form a chain:
layouts/app.blade.php— the base HTML shell:<!doctype html>,<head>, meta and asset links, the dark-mode boot, and the body wrapper. It contains no header or footer.layouts/frontend.blade.php—@extendses the app layout and fills it with the visible chrome: header, a<main>landmark, and footer.
layouts/app.blade.php is one of the three required files a theme must ship (alongside sections.json and theme.json — see /theme-development/theme-anatomy). Get this pair right and every page type inherits a correct, consistent shell for free.
The two-section chain#
The chain nests two named sections. The base yields an outer section for the whole page body; the frontend layout fills that outer section with chrome and yields an inner section for the page's own content.
| Layer | File | Provides | Yields |
|---|---|---|---|
| Base shell | layouts/app.blade.php | <html>, <head>, <body> | @yield('app') |
| Chrome | layouts/frontend.blade.php | header + <main> + footer | @yield('content') (inside <main>) |
| Page | e.g. home.blade.php | the actual page | fills @section('content') |
A controller page extends layouts.frontend and defines @section('content'). The frontend layout wraps that in header/footer and re-emits it as @section('app'). The base shell drops @yield('app') into <body>. One document, three clean layers.
The base shell — layouts/app.blade.php#
Keep the base focused on the document: <head> contents, asset links, the mode boot, and the body wrapper. Reference your own CSS and JS through `themeAsset()` so they get an mtime cache-buster (?v=<mtime>) — uploaded themes ship precompiled assets that aren't fingerprinted by a bundler, so this is what forces browsers and Cloudflare off a stale copy after an update. See /theme-development/theme-assets for the full asset rules.
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"
dir="{{ in_array(app()->getLocale(), ['ar','fa','he','ur'], true) ? 'rtl' : 'ltr' }}"
class="scroll-smooth">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ gs()->siteName(__($pageTitle ?? '')) }}</title>
<meta name="description" content="{{ $pageDescription ?? gs('site_description') }}">
<meta name="theme-color" content="{{ themeMeta('colors.primary', '#111114') }}">
<link rel="canonical" href="{{ url()->current() }}">
<link rel="icon" type="image/png" href="{{ siteFavicon() }}">
{{-- Your theme's precompiled CSS — themeAsset() adds the ?v=<mtime> cache-buster --}}
<link rel="stylesheet" href="{{ themeAsset('css/theme.css') }}">
{{-- Dark-mode boot: set the class BEFORE first paint so there's no flash --}}
<script nonce="{{ csp_nonce() }}">
(function () {
var m = localStorage.getItem('theme');
if (m === 'dark' || (!m && matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
@livewireStyles
@stack('style')
</head>
<body class="font-sans antialiased bg-[var(--theme-bg-light)] text-[var(--theme-text-light)]
dark:bg-[var(--theme-bg-dark)] dark:text-[var(--theme-text-dark)]">
<a href="#main-content" class="skip-link">{{ __('Skip to content') }}</a>
{{-- The Livewire slot short-circuit — see below --}}
@if(isset($slot))
{{ $slot }}
@else
@yield('app')
@endif
@livewireScripts
<script src="{{ themeAsset('js/theme.js') }}" nonce="{{ csp_nonce() }}"></script>
@stack('script')
</body>
</html>Notes on the shell:
- `$pageTitle` / `$pageDescription` are the variables controllers pass in. Feed the title through
gs()->siteName(...)so it picks up the operator's configured site name suffix. The reference theme layers full SEO (Open Graph, Twitter, JSON-LD) on top of this — copy it fromsalieno/layouts/app.blade.phpwhen you want the complete set; it is not required for a working theme. - `csp_nonce()` must be on every inline
<script>/<style>— Core sends a strict Content-Security-Policy and a nonce-less inline script is blocked. This is not optional. - The dark-mode boot runs inline in `<head>`, before paint, to avoid a light-then-dark flash. The mode switcher and the token system are covered in /theme-development/theme-dark-mode.
The Livewire slot short-circuit — why it exists#
Look again at the body:
@if(isset($slot))
{{ $slot }}
@else
@yield('app')
@endifThis one conditional is what lets the same base layout serve both Blade-inherited pages and full-page Livewire components.
- A controller page reaches the layout through
@extends(...)/@section(...). No$slotis in scope, so the shell renders@yield('app')and the normal section inheritance produces the page. - A full-page Livewire component (the auth screens in /theme-development/theme-auth, or a store page) is handed to its layout as a
$slotvariable — that is Livewire's component-layout contract. When Livewire uses yourlayouts.appdirectly as the layout,$slotis in scope and the shell renders it.
Without the @if(isset($slot)) branch, a Livewire component pointed at your layout would render an empty page — @yield('app') would be blank because no @section('app') was defined. The short-circuit is therefore mandatory in the base shell. Keep it exactly as shown.
The chrome — layouts/frontend.blade.php#
The frontend layout extends the base and supplies everything visible around the page body. This is where header and footer live, so every controller page gets them without repeating markup.
@extends(activeTheme() . 'layouts.app')
@section('app')
@include(activeTheme() . 'partials.header')
{{-- Focusable main landmark: the skip-link and SPA navigation move focus here --}}
<main id="main-content" tabindex="-1" aria-label="{{ __('Main content') }}"
class="focus:outline-none">
@yield('content')
</main>
@include(activeTheme() . 'partials.footer')
@endsectionThat is the real reference frontend.blade.php, nearly verbatim. Two things to keep:
- `activeTheme()` returns the string
"themes.{name}.", soactiveTheme() . 'layouts.app'resolves to your theme's app layout andpartials.header/partials.footerresolve to your own partials. Never hardcodethemes.salieno.— always concatenateactiveTheme()so the view names track whichever theme is active. (activeTemplate()is the deprecated alias; useactiveTheme().) - `<main id="main-content" tabindex="-1">` is the focus target for the
skip-linkin the base shell. Keep the id andtabindex="-1"so keyboard and screen-reader users can jump past the header.
Header and footer are ordinary partials you author however you like — they are not part of the required file set. What matters is that <main> yields content, because that is the section every page fills.
How a controller page uses the chain#
FrontendController::themeView('home') renders themeViewName('home') → themes.{name}.home. That page extends the frontend layout and defines content:
{{-- themes/{name}/home.blade.php --}}
@extends(activeTemplate() . 'layouts.frontend')
@section('content')
@include(activeTemplate() . 'partials.render-sections')
@endsectionThe controller also passes $pageTitle/$pageDescription/$seoContents into the view; the base shell reads them from <head>. That is the entire contract: extend layouts.frontend, fill @section('content'), and the two-layer shell does the rest. The render-sections include and the section pipeline are covered in /theme-development/theme-sections; the full list of pages a complete theme must ship is in /theme-development/theme-pages.
The store bridge — full-page components inside your chrome#
Some storefront pages are full-page Livewire components rather than controller views — for example the product comparison page, App\Livewire\Store\ComparePage. These need the same header/footer chrome as controller pages, but Livewire hands their content in as $slot, which the base shell's short-circuit would render bare (no chrome).
Core solves this with a small, theme-transparent bridge layout, resources/views/components/layouts/store.blade.php, which the component selects with #[Layout('components.layouts.store')]. You do not write or override this file — it ships with Core — but understanding it explains how those pages end up inside your theme:
@php
// Capture Livewire's $slot into our own variable, remember the #[Title]…
$storePageContent = $slot;
$pageTitle = $pageTitle ?? ($title ?? '');
// …then UNSET $slot so the base shell does NOT take its short-circuit.
unset($slot);
@endphp
@extends(activeTheme() . 'layouts.frontend')
@section('content')
{{ $storePageContent }}
@endsectionThe trick is deliberate: because the base shell renders {{ $slot }} whenever $slot is in scope, the bridge captures the slot, then `unset($slot)` before extending layouts.frontend. With $slot gone, the normal @yield('app') → header/<main>/footer → @yield('content') inheritance runs, and the component's content lands inside your theme's chrome exactly like a controller page. Because it extends activeTheme() . 'layouts.frontend', it automatically uses whichever theme is active — including yours, with no wiring on your part. Your only obligation is to ship a correct layouts.frontend.
Checklist for a correct layout pair#
layouts/app.blade.phpships (it is required), contains the full<head>, and its<body>uses the@if(isset($slot)) {{ $slot }} @else @yield('app') @endifshort-circuit.- Every inline
<script>/<style>carriesnonce="{{ csp_nonce() }}". - The dark-mode class is set inline in
<head>before paint. - Own CSS/JS are linked via
themeAsset(...), not a bareasset(...). layouts/frontend.blade.phpextendsactiveTheme() . 'layouts.app', fills@section('app')with header +<main id="main-content">@yield('content')</main>+ footer.- All internal view/partial references concatenate
activeTheme()— no hardcoded theme name.
Next: /theme-development/theme-assets — how CSS, JavaScript and images are shipped, published, cache-busted, and security-screened.