Customize PagibleAI Theme

PagibleAI resolves each frontend page through its active theme and falls back to the base theme when a view or asset is missing. You can override the base theme inside one Laravel application or package a theme for reuse across projects.

Use application overrides for a small site-specific change. Create a theme package when the design has its own layouts, content elements, assets or configuration.

Source compatibility: This guide was verified against PagibleAI commit 3c149a3 on 2026-08-01. If you use a tagged release, compare signatures with the package installed in your application.

Current implementation references: frontend helper functions, base document layout, navigation view model and base theme schema.

Publish and change theme assets

The base theme publishes CSS and JavaScript to public/vendor/cms/theme/. Run php artisan vendor:publish --tag=cms-theme after installation or when you deliberately want to publish missing assets.

You can edit an already-published file for a small application-specific change. Laravel does not overwrite an existing published file unless you publish with --force, so compare your copy with the package version during upgrades. For a reusable or extensive design, put the changed assets in a custom theme package instead.

The main files are:

  • cms.css for critical styles loaded in the document head
  • cms-lazy.css for styles loaded near the end of the document
  • cms.js and csrf.js for shared frontend behavior

Element templates can load their own assets with Blade stacks. For example:

@pushOnce('head')
<link href="{{ cmstheme($page, 'quote.css') }}" rel="stylesheet">
@endPushOnce

@pushOnce('foot')
<script defer src="{{ cmstheme($page, 'quote.js') }}"></script>
@endPushOnce

cmstheme() uses the page's active theme and falls back to public/vendor/cms/theme/ when that theme does not provide the requested file. @pushOnce prevents duplicate tags when an element appears more than once.

Override base-theme views

Laravel application overrides for the base cms view namespace live in resources/views/vendor/cms/. Copy only the templates you need from vendor/aimeos/pagible-theme/views/, keeping the same relative path.

Examples:

  • vendor/aimeos/pagible-theme/views/hero.blade.phpresources/views/vendor/cms/hero.blade.php
  • vendor/aimeos/pagible-theme/views/layouts/main.blade.phpresources/views/vendor/cms/layouts/main.blade.php

Never edit files inside vendor/; Composer replaces them during updates. See Laravel's package view customization documentation for the underlying override mechanism.

Understand the main layout

The base document layout is vendor/aimeos/pagible-theme/views/layouts/main.blade.php. It renders metadata, navigation and breadcrumbs, yields the page-specific sections, and then includes layouts/foot.blade.php for lazy assets and editor integration.

A shortened outline looks like this:

<!DOCTYPE html>
<html lang="{{ cms($page, 'lang') }}">
    <head>
        <title>{{ cms($page, 'title') }}</title>

        @foreach(cms($page, 'meta', []) as $item)
            @includeFirst(cmsviews($page, $item), cmsdata($page, $item))
        @endforeach

        <link href="{{ cmstheme($page, 'cms.css') }}" rel="stylesheet">
        @stack('head')
    </head>
    <body>
        <main id="main">
            @yield('main')
        </main>

        @yield('footer')
        @include('cms::layouts.foot')
    </body>
</html>

Content Security Policy

Outside debug mode, the base layout sends a Content Security Policy through a <meta> element. External sources come from the cms.theme.csp.* configuration, while inline styles and scripts configured on a page or ancestor are authorized by SHA-256 hashes generated with cmshashes().

The relevant directives are:

style-src 'self'
    {{ config('cms.theme.csp.style-src') }}
    {!! cmshashes($page, 'config.styles.data.text') !!};

script-src 'self'
    {{ config('cms.theme.csp.script-src') }}
    {!! cmshashes($page, 'config.javascript.data.text') !!};

Configure additional sources with:

  • CMS_CSP_MEDIA_SRC for images and media; its default includes the external demo-media and Iconify hosts
  • CMS_CSP_STYLE_SRC for stylesheets
  • CMS_CSP_FRAME_SRC for frames
  • CMS_CSP_SCRIPT_SRC for scripts
  • CMS_CSP_CONNECT_SRC for browser connections

Add only sources you trust. You do not need unsafe-inline for the page-level custom CSS and JavaScript fields because the layout calculates their hashes.

Head and foot assets

Use @stack('head') for critical element CSS and @stack('foot') for deferred assets. The base foot partial loads lazy theme files and conditionally adds the frontend editor:

<link href="{{ cmstheme($page, 'cms-lazy.css') }}" rel="preload" as="style">
<script defer src="{{ cmstheme($page, 'csrf.js') }}"></script>
<script defer src="{{ cmstheme($page, 'cms.js') }}"></script>
@stack('foot')

@if(\Aimeos\Cms\Permission::can('page:save', auth()->user()))
    @includeIf('cms::editor')
@else
    <script defer src="{{ cmstheme($page, 'stats.js') }}"></script>
@endif

The cms::editor view is provided by the admin package. Keep this conditional include if editors should be able to open the visual page editor from the frontend.

Navigation

Layouts receive a request-local $nav object. It applies publication status and frontend access rules before returning items, so the template does not need to repeat those checks.

Render the configured navigation depth with $nav->items():

<ul>
    @foreach($nav->items() as $item)
        <li>
            @if($item->children->count())
                <details>
                    <summary>{{ cms($item, 'name') }}</summary>
                    <ul>
                        @foreach($item->children as $child)
                            <li>
                                <a href="{{ cmsroute($child) }}">
                                    {{ cms($child, 'name') }}
                                </a>
                            </li>
                        @endforeach
                    </ul>
                </details>
            @else
                <a href="{{ cmsroute($item) }}">
                    {{ cms($item, 'name') }}
                </a>
            @endif
        </li>
    @endforeach
</ul>

CMS_NAVDEPTH controls how many descendant levels are loaded; its default is 2. Pass a zero-based ancestor level to $nav->items($level) when a layout needs to start lower in the tree.

Breadcrumbs

Use the same navigation object for visible, access-filtered ancestors:

@if($nav->ancestors()->count() > 1)
    <nav aria-label="{{ __('Breadcrumb navigation') }}">
        <ul>
            @foreach($nav->ancestors()->skip(1) as $item)
                <li>
                    <a href="{{ cmsroute($item) }}">{{ cms($item, 'name') }}</a>
                </li>
            @endforeach
            <li>{{ cms($page, 'name') }}</li>
        </ul>
    </nav>
@endif

Build page layouts

Page layouts live below views/layouts/ and extend the active theme's main layout. Their editable regions must match the sections configured for that page type in schema.json.

The base page layout renders the main and footer groups:

@extends($theme . '::layouts.main')

@pushOnce('head')
<link href="{{ cmstheme($page, 'layout-page.css') }}" rel="stylesheet">
@endPushOnce

@section('main')
    <div class="cms-content" data-section="main">
        @foreach($content['main'] ?? [] as $item)
            @if($element = cmsref($page, $item))
                <div id="{{ cmsattr($item->id ?? '') }}"
                    class="{{ cmsattr($element->type ?? '') }}">
                    <div class="container">
                        @includeFirst(
                            cmsviews($page, $element),
                            cmsdata($page, $element)
                        )
                    </div>
                </div>
            @endif
        @endforeach
    </div>
@endsection

@section('footer')
    <footer class="cms-content" data-section="footer">
        @foreach($content['footer'] ?? [] as $item)
            @if($element = cmsref($page, $item))
                <div id="{{ cmsattr($item->id ?? '') }}"
                    class="{{ cmsattr($element->type ?? '') }}">
                    <div class="container">
                        @includeFirst(
                            cmsviews($page, $element),
                            cmsdata($page, $element)
                        )
                    </div>
                </div>
            @endif
        @endforeach
    </footer>
@endsection

cms-content enables frontend editing for the region. data-section tells the editor where a newly inserted element belongs. cmsref() resolves shared-element references, cmsviews() returns the view candidates, and cmsdata() prepares the template variables.

Build content-element views

A content element's Blade filename matches its schema key. For a custom quote element, create views/quote.blade.php in the theme package:

@pushOnce('head')
<link href="{{ cmstheme($page, 'quote.css') }}" rel="stylesheet">
@endPushOnce

<figure class="quote">
    <blockquote>@text($data->text ?? '')</blockquote>

    @if($data->author ?? null)
        <figcaption>{{ $data->author }}</figcaption>
    @endif
</figure>

The template receives $page, $data and $files. Escape ordinary values with Blade's {{ ... }} syntax. Use @text for limited inline Markdown or @markdown for full Markdown. Resolve selected files with cmsfile($page, $id) and generate their URLs with cmsasset($page, $file).

Create a reusable theme package

For a reusable design, keep the Composer manifest, schema, service provider, main layout, assets, translations and tests in a separate package. Follow Create a PagibleAI Theme Package for the upcoming 0.12 package structure, local installation, regression test and release checklist.

Troubleshoot theme changes

  • The theme is missing in the page editor: confirm Composer discovered the service provider and that the same identifier is used by Schema::register(), the Blade namespace and the page's theme value.
  • A Blade change is not visible: run php artisan optimize:clear to clear compiled views and application caches.
  • A theme asset returns 404: run php artisan vendor:publish --tag=cms-theme and check public/vendor/cms/<theme>/. Add --force only when you intend to overwrite already published assets.
  • The browser reports a CSP violation: add the required host to the matching CMS_CSP_* setting. Keep inline style and script output paired with the same cmshashes() config paths used by the CSP.

Template helper reference

  • cms($item, $property, $default = null) reads a nested published value, or the latest draft value for an editor with page:view.
  • cmsasset($page, $file, $variant = null) returns a public file URL or an access-controlled URL for a private file. The optional variant is a preview width or preview path.
  • cmsattr($value) sanitizes a value for an HTML id or class attribute.
  • cmsdata($page, $item) prepares an element's template data, files and schema-defined action result.
  • cmsfile($page, $fileId) retrieves a file attached to the page.
  • cmshashes($page, $path) returns CSP hashes for inline style or script values inherited from the page tree.
  • cmsjson($value) encodes JSON safely for a <script> block.
  • cmslink($url) allows relative links and the http, https, mailto and tel schemes; unsafe schemes return an empty string.
  • cmsplain($markdown) creates best-effort plain text for structured data.
  • cmsref($page, $item) resolves a shared-element reference.
  • cmsroute($page) returns the page URL or its redirect target.
  • cmssrcset($page, $file) builds responsive preview URLs for a file.
  • cmstheme($page, $file, $version = true) returns a theme asset URL with base-theme fallback and optional cache-busting version.
  • cmsurl($path) returns a URL from the configured public CMS disk while preserving remote HTTP URLs.
  • cmsviews($page, $item) returns ordered theme and base-theme view candidates, ending with cms::invalid.