PagibleAI provides a flexible theming system that allows you to customize the look and feel of your website easily. This document outlines how to modify the views, CSS, and JavaScript files to achieve your desired design.
Customize PagibleAI Theme
Modify CSS and JavaScript
The CSS and JavaScript files for the PagibleAI theme are published to the ./public/vendor/cms/theme/ directory during installation. The base styles are split into two files: cms.css holds the critical above-the-fold styles that are loaded in the page <head>, while cms-lazy.css contains the below-the-fold styles that are loaded with lower priority for better performance. You can directly modify these files to customize the theme's styling and functionality.
Example: Modifying the main CSS file
- Navigate to the
./public/vendor/cms/theme/directory. - Open the
cms.cssorcms-lazy.css(or any other CSS file you want to modify). - Make your desired changes.
- Save the file.
The same process applies to JavaScript files. If content elements need CSS styling or Javascript code, its stored in files specific for this content element, i.e. the contact form includes a contact.css and contact.js file in its Blade view. Therefore, only styles and code from elements used in the page are sent to the browser resulting in high performance scores in Goolge Lighthouse metrics.
Caution: Be aware that the CSS and JavaScript files in the ./public/vendor/cms/theme/ directory are not automatically updated when you install a new version of PagibleAI. This means that any modifications you make to these files will persist across updates, but you may need to manually merge any changes from the new version of PagibleAI into your customized files to ensure compatibility and access to the latest features.
Override Views
To customize the look and feel of PagibleAI, you can override the default views by placing your own versions in the ./resources/views/vendor/cms/ directory of your Laravel application. This allows you to tailor the appearance of various components to match your website's design. This approach leverages Laravel's view customization capabilities which provides a clean and maintainable way to modify package views without directly altering the package's core files.
For instance, to customize the hero.blade.php view, you would first copy the original file from the PagibleAI package into your application's directory:
./resources/views/vendor/cms/hero.blade.php
The page layouts are available at:
./resources/views/vendor/cms/layouts/
For example, the main layout file is in:
./resources/views/vendor/cms/layouts/main.blade.php
After copying the file, you can freely modify it to implement your desired changes. Laravel will automatically load your custom view instead of the default one provided by PagibleAI. This process ensures that your customizations are preserved even when you update the PagibleAI package. For more information on how Laravel handles views, refer to the official Laravel documentation on views.
Adapt main layout
Layouts within PagibleAI are essentially Laravel Blade templates. Since the 0.11 release the default theme ships as its own package, so the main layout of the default theme is located in this file:
./vendor/aimeos/pagible-theme/views/layouts/main.blade.php
To customize the layout, you must not modify the file directly in the vendor directory. Instead, copy it to the cms view namespace override directory:
./resources/views/vendor/cms/layouts/main.blade.php
If you make your changes there, it ensures your customizations are preserved during updates.
Content Security Policy
The main template contains a Content Security Policy (CSP), which is an added layer of security that helps to detect and mitigate certain types of attacks, including Cross Site Scripting (XSS) and data injection attacks. CSP works by restricting the sources from which the browser is allowed to load resources. By defining a strict CSP, you can prevent the browser from loading malicious scripts or other content from unauthorized sources. The CSP rules included in the template are:
base-uri 'self';
default-src 'self';
frame-src 'self' <cms.theme.csp.frame-src>;
connect-src 'self' <cms.theme.csp.connect-src>;
img-src 'self' data: blob: <cms.theme.csp.media-src>;
media-src 'self' data: blob: <cms.theme.csp.media-src>;
style-src 'self' 'nonce-...' <cms.theme.csp.style-src>;
script-src 'self' 'nonce-...' <cms.theme.csp.script-src>;
The CSP directives are configurable via environment variables in your .env file:
CMS_CSP_MEDIA_SRC: Additional sources for images and mediaCMS_CSP_STYLE_SRC: Additional sources for stylesheets (defaults to hCaptcha domains)CMS_CSP_FRAME_SRC: Additional sources for iframes (defaults to hCaptcha domains)CMS_CSP_SCRIPT_SRC: Additional sources for scripts (defaults to hCaptcha domains)CMS_CSP_CONNECT_SRC: Additional sources for AJAX/fetch requests (defaults to hCaptcha domains)
By default, only the current domain and hCaptcha domains are allowed. To add additional trusted sources, set the corresponding environment variable, e.g.:
CMS_CSP_SCRIPT_SRC="https://hcaptcha.com https://*.hcaptcha.com https://cdn.example.com"
Page Head Elements
Meta tags are dynamically generated within the layout using the following Blade code:
@foreach(cms($page, 'meta', []) as $item)
@includeFirst(cmsviews($page, $item), cmsdata($page, $item))
@endforeach
It renders all elements from the meta section of the page added in the admin backend according to their views and content.
Theme assets are referenced with the cmstheme() helper, which resolves a file from the page's theme package and automatically falls back to the base theme if the file does not exist. In the HTML header, the critical CSS is loaded together with the @stack('head') directive. The @stack('head') and @stack('foot') directives are Blade features that allow you to push CSS and JavaScript from content element templates onto stacks, which are then rendered in the page head or right before the closing body tag respectively:
<link href="{{ cmstheme($page, 'pico.min.css') }}" rel="stylesheet">
<link href="{{ cmstheme($page, 'pico.nav.min.css') }}" rel="stylesheet">
<link href="{{ cmstheme($page, 'pico.dropdown.min.css') }}" rel="stylesheet">
<link href="{{ cmstheme($page, 'cms.css') }}" rel="stylesheet">
@stack('head')
The below-the-fold styles (cms-lazy.css) and the JavaScript are loaded at the end of the body together with the @stack('foot') directive for better performance:
<link href="{{ cmstheme($page, 'pico.modal.min.css') }}" rel="preload" as="style">
<link href="{{ cmstheme($page, 'cms-lazy.css') }}" rel="preload" as="style">
<script defer src="{{ cmstheme($page, 'cms.js') }}"></script>
@stack('foot')
This is useful for including CSS and JS files that are specific to certain sections of a page or individual content elements. By using stacks and @pushOnce, you can ensure that all necessary CSS and JavaScript is only loaded if required and only once, even if a content element is used multiple times on a page.
The page editor in the admin backend requires specific CSS and JavaScript files to function correctly. These files must be included conditionally based on the user's permissions using the following code:
@if(\Aimeos\Cms\Permission::can('page:save', auth()->user()))
<link href="{{ cmsasset('vendor/cms/admin/editor.css') }}" rel="stylesheet">
<script defer src="{{ cmsasset('vendor/cms/admin/editor.js') }}"></script>
@endif
Main Navigation
The page navigation is dynamically generated using the following Blade code:
@foreach($page->nav() as $item)
@if(cms($item, 'status') == 1)
<li>
@if($item->children->count())
<details class="dropdown is-menu">
<summary role>{{ cms($item, 'name') }}</summary>
<ul class="align">
@foreach($item->children as $subItem)
@if(cms($subItem, 'status') == 1)
<li>
<a href="{{ cmsroute($subItem) }}"
class="{{
$page->isSelfOrDescendantOf($subItem)
? 'active'
: ''
}} contrast">
{{ cms($subItem, 'name') }}
</a>
</li>
@endif
@endforeach
</ul>
</details>
@else
<a href="{{ cmsroute($item) }}"
class="{{
$page->isSelfOrDescendantOf($item)
? 'active'
: ''
}} contrast">
{{ cms($item, 'name') }}
</a>
@endif
</li>
@endif
@endforeach
This code snippet creates a two-level navigation menu, rendering sub-menus where necessary. It uses the $page->nav() method, which returns a nested set collection from the kalnoy/nestedset Laravel package representing the hierarchical navigation structure. The depth of this structure is configurable by changing the navdepth setting in ./config/cms.php.. You can also retrieve a specific level of the navigation tree using $page->nav(x), where x is the desired starting level.
While the code primarily uses simple properties like children and methods like count(), here are some useful methods provided by the kalnoy/nestedset Laravel package that you might find helpful when building a more complex navigation:
children: A relationship that returns a collection of the node's direct children. Used directly in the code.parent: A relationship that returns the parent node.ancestors: Returns a collection of all ancestor nodes, up to the root. This could be useful for generating breadcrumbs.descendants: Returns a collection of all descendants of the node (children, grandchildren, etc.).count(): Returns the number of nodes at the current level.depth: Returns the depth of the node in the tree (root node has a depth of 0).isRoot(): Returnstrueif the node is the root node (has no parent).isLeaf(): Returnstrueif the node is a leaf node (has no children).getPath(): Returns a collection of ancestor nodes including the current node. Usefull for creating breadcrumbs.$page->isSelfOrDescendantOf($item): Retunstrueif the$pagenode is a descendant of$itemor the $item itself.
The code also utilizes a few CMS helper functions:
cms(): Retrieves data from CMS items (pages, content elements, etc.).cmsroute(): Generates the URL for a CMS page.
See "Template Helper Functions" for more detailed information.
Breadcrumb Navigation
Breadcrumbs are generated using the following Blade code:
@if($page->ancestors->count() > 1)
<nav aria-label="breadcrumb">
<ul>
@foreach($page->ancestors->skip(1) as $item)
@if(cms($item, 'status') == 1)
<li>
<a href="{{ cmsroute($item) }}">{{ cms($item, 'name') }}</a>
</li>
@else
@break
@endif
@endforeach
<li>{{ cms($page, 'name') }}</li>
</ul>
</nav>
@endif
This code first checks if the current page has more than one ancestor (meaning it's not the homepage). If it does, it renders a breadcrumb navigation. The code iterates through the page's ancestors (parent pages), skipping the first one (the homepage).
For each ancestor, it checks its status to ensure that only active pages are included in the breadcrumb. The cmsroute() helper function generates the URL to the page, and cms($item, 'name') retrieves the name of the page to display in the breadcrumb. The last element in the breadcrumb is the current page's name.
Content from Page Templates
The @yield('main') directive is a placeholder in the main layout where the content of individual pages will be injected. When a page layout extends the main layout using @extends($theme . '::layouts.main'), the content defined within the @section('main') ... @endsection block in the page layout will be inserted into the main layout at the location of the @yield('main') directive. The $theme variable is passed to every template and holds the view namespace of the page's theme, so the same template works for any theme package.
You can also inject more sections defined in the different page layouts. If the sections should contain editable content, you need to add the section name to the theme's schema.json file (the theme configuration that previously lived in ./config/cms.php has been moved into the theme packages).
Footer
The footer is not part of the main layout directly — instead, main.blade.php contains a @yield('footer') placeholder, and the footer content is defined in page layouts (e.g., page.blade.php) within a @section('footer') block. The footer is generated using the following Blade code:
@section('footer')
<footer class="cms-content" data-section="footer">
@foreach($content['footer'] ?? [] as $item)
@if($el = cmsref($page, $item))
<div id="{{ cmsattr(@$item->id) }}" class="{{ cmsattr(@$el->type) }}">
<div class="container">
@includeFirst(cmsviews($page, $el), cmsdata($page, $el))
</div>
</div>
@endif
@endforeach
</footer>
<footer class="copyright">
© {{ date('Y') }} {{ config('app.name') }}
</footer>
@endsection
This code iterates through the content elements assigned to the "footer" content group. The cmsref() function resolves references if $item is a shared element. The @includeFirst directive dynamically includes the appropriate Blade view for the content element based on its type, passing in the relevant data. A copyright line with the current year and application name is also appended.
The cms-content CSS class enables the page editor to change the element while the data-section attribute tells the page editor to which section new content elements should be added to.
Customize page layouts
Layouts within PagibleAI are essentially Laravel Blade templates. The default layouts are located in the ./vendor/aimeos/pagible-theme/views/layouts/ directory. However, you should never modify files directly within the vendor directory, as changes will be overwritten during updates.
To customize these layouts, you can override them by creating corresponding files in the ./resources/views/vendor/cms/layouts/ directory of your Laravel application. This approach allows you to modify the layout structure and design without affecting the core PagibleAI files.
When creating your custom page layout, you'll typically use Blade directives like @extends($theme . '::layouts.main') and @section('main'). Here's the Blade template of the standard page template:
@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($el = cmsref($page, $item))
<div id="{{ cmsattr($item->id ?? '') }}" class="{{ cmsattr($el->type ?? '') }}">
<div class="container">
@includeFirst(cmsviews($page, $el), cmsdata($page, $el))
</div>
</div>
@endif
@endforeach
</div>
@endsection
Let's delve deeper into these directives:
@extends($theme . '::layouts.main'): This directive is the cornerstone of layout inheritance in Blade. It tells your custom layout to inherit the structure and content from the parent layout of the page's theme. The$themevariable holds the view namespace of the active theme (for examplecmsfor the default theme), so the same template works for every theme package. This parent layout provides the basic HTML structure, including the<html>,<head>, and<body>tags, along with common elements like headers, footers, and navigation.@pushOnce('head'): This directive ensures that the CSS stylesheet defined within it (in this case,layout-page.css) is only included once on the page, regardless of how many times the template is included or rendered. This prevents duplicate CSS rules and keeps your page loading efficiently. Thecmstheme()helper function generates the correct URL to the asset within the active theme package, automatically falling back to the base theme if the file does not exist.@section('main')... @endsection: This directive defines a specific section within your custom layout. A section acts as a placeholder where you can inject content that will be displayed in a corresponding@yielddirective in the parent layout. The namemainmust be the same name used in the@yielddirective in the parent layout where you want this content to be inserted.
Inside the main section, the <div class="cms-content" data-section="main"> serves two purposes: The CSS class cms-content enables the editor to change the content element in the page editor view of the admin backend while the data section main defines the content group new elements are assigned to when using the page editor.
Then, the code iterates through the $content['main'] array, which contains the content elements assigned to the "main" content group of your page in the CMS. The available content groups are defined in the theme's schema.json file. All configured content groups with content elements are available in the $content variable in the template, which is an associative array.
The cmsref($page, $item) function resolves references if $item is a shared element. Otherwise, the content element itself is returned. If the content element exists, it's wrapped in a div with an ID and class based on the element's type. This is important for the page editor to find the corresponding content element the editor wants to modify in the page editor view.
Finally, the @includeFirst(cmsviews($page, $el), cmsdata($page, $el)) dynamically includes the appropriate Blade view for the content element, passing in the relevant data. This allows you to build up the page's content from reusable content elements managed within the PagibleAI CMS.
Customize content elements
This Blade template is responsible for rendering a "hero" content element within the PagibleAI CMS:
@pushOnce('head')
<link href="{{ cmstheme($page, 'hero.css') }}" rel="stylesheet">
@endPushOnce
<div class="first">
@if(@$data->subtitle)
<div class="subtitle">
{{ $data->subtitle }}
</div>
@endif
<h1 class="title">{{ @$data->title }}</h1>
@if(@$data->text)
@markdown($data->text)
@endif
@if(@$data->url)
<a class="btn url" href="{{ $data->url }}">{{ @$data->button }}</a>
@endif
</div>
@if($file = cms($files, @$data->file?->id))
<div class="second">
@include('cms::pic', ['file' => $file, 'main' => true, 'sizes' => '50vw'])
</div>
@endif
Let's break down its functionality:
@pushOnce('head'): This directive ensures that the CSS stylesheet for the hero element (hero.css) is included only once in the page head, even if multiple hero elements are present. This prevents redundant CSS loading. Thecmstheme($page, 'hero.css')helper returns the full URL to the stylesheet within the active theme package, falling back to the base theme if the file does not exist.- The template is structured into two sections:
<div class="first">for the text content and<div class="second">for an optional image. {{ $data->subtitle }}: Displays an optional subtitle above the main title.{{ @$data->title }}: Displays the hero's main title using the null-safe@operator, which prevents errors if the title is not set.@markdown($data->text): Renders the hero's descriptive text as Markdown.@if(@$data->url): Conditionally renders a call-to-action button linking to the specified URL, with the button text from$data->button.- The image section uses
cms($files, @$data->file?->id)to look up the file object and thecms::picpartial to render a responsive image withsrcset.
In summary, this Blade template dynamically renders a hero content element with two sections: a text area containing an optional subtitle, a title, optional Markdown text, and a call-to-action button, alongside an optional image. The @pushOnce directive ensures efficient CSS loading, and the conditional @if statements handle optional fields gracefully. The cms::pic partial is used to render responsive images with appropriate srcset attributes.
Create a Theme Package
Instead of customizing the default theme in your application, you can package your design as a reusable, installable theme. A theme is a regular Laravel package that is auto-discovered through Composer and requires the base aimeos/pagible-theme package. Because views and assets fall back to the base theme, your package only needs to ship the parts it actually changes — a typical custom theme overrides views/layouts/main.blade.php, provides its own cms.css / cms-lazy.css and defines a few theme colors, while inheriting all content element templates from the base theme.
The four official themes (aimeos/pagible-themes-pagible, aimeos/pagible-themes-glass, aimeos/pagible-themes-paper and aimeos/pagible-themes-premium) are built exactly this way and serve as good references.
Package Structure
A theme package uses the following directory layout:
my-theme/
├── composer.json
├── schema.json Theme metadata, page types, sections and config fields
├── src/
│ └── MyThemeServiceProvider.php Registers the theme name, views and assets
├── public/ CSS/JS published to public/vendor/cms/<name>/
│ ├── cms.css Critical above-the-fold styles
│ ├── cms-lazy.css Below-the-fold styles
│ └── ... Additional component styles you override
└── views/ Blade templates you override (others fall back)
└── layouts/
└── main.blade.php
Throughout this guide the theme identifier mytheme is used. It is the name editors select in a page's "Theme" field, the view namespace your templates resolve to, and the sub-directory your assets are published into (public/vendor/cms/mytheme/).
composer.json
Declare the package and its dependency on the base theme, register the PSR-4 autoloading for the shared Aimeos\Cms\ namespace and let Laravel auto-discover your service provider:
{
"name": "myvendor/pagible-theme-mytheme",
"description": "My custom Pagible CMS theme",
"type": "library",
"license": "proprietary",
"require": {
"php": "^8.2",
"aimeos/pagible-theme": "*"
},
"autoload": {
"psr-4": {
"Aimeos\\Cms\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Aimeos\\Cms\\MyThemeServiceProvider"
]
}
}
}
All PagibleAI packages share the Aimeos\Cms\ namespace, so use it for your service provider too and give the class a unique name.
Service Provider
<?php
namespace Aimeos\Cms;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider as Provider;
class MyThemeServiceProvider extends Provider
{
public function boot(): void
{
$basedir = dirname( __DIR__ );
Schema::register( $basedir, 'mytheme' );
View::addNamespace( 'mytheme', $basedir . '/views' );
$this->publishes( [$basedir . '/public' => public_path( 'vendor/cms/mytheme' )], 'cms-theme' );
}
}
The boot() method wires the theme into the CMS with three calls:
Schema::register($basedir, 'mytheme'): Loads the theme'sschema.jsonand registers it under the namemytheme. Page types and their sections are merged with the other installed themes, while anycontent,metaandconfigschema definitions are namespaced to your theme so they never collide with another theme's fields.View::addNamespace('mytheme', $basedir . '/views'): Registers the Blade view namespace so@extends($theme . '::layouts.main')andcmsviews()resolve to your templates. Use the same identifier as inSchema::register().$this->publishes([...], 'cms-theme'): Publishes yourpublic/directory topublic/vendor/cms/mytheme/when the user runsphp artisan vendor:publish --tag=cms-theme. Thecmstheme()helper loads assets from there and falls back to the base theme for any file you don't ship.
schema.json
The schema.json file describes the theme. The label, description, maintainer, email and website keys are shown in the admin panel's theme selector. The types object defines the available page types and the content sections (layout regions) each one exposes — these section names become the valid content groups for that page type. The optional content, meta and config objects define the field schemas for content elements, meta sections and page configuration; omit them to inherit the definitions from the base theme, or add them to introduce new fields. The config.theme field group holds the editable theme variables — typically Pico CSS --pico-* custom properties — that editors can adjust in the admin panel and that are read in templates via cms($page, 'config.theme.data.--pico-...').
{
"label": "My Theme",
"description": "My custom Pagible CMS theme",
"maintainer": "My Company",
"email": "info@example.com",
"website": "https://example.com",
"types": {
"page": { "sections": ["main", "footer"] },
"docs": { "sections": ["main", "footer"] },
"blog": { "sections": ["main", "footer"] }
},
"config": {
"theme": {
"group": "basic",
"fields": {
"--pico-primary": { "type": "color", "default": "#2563EB" },
"--pico-background-color": { "type": "color", "default": "#FFFFFF" }
}
}
}
}
Install and Activate
Once the package is available (via Packagist, a private repository or a local path/VCS repository entry in your application's composer.json), install and activate it:
- Require the package:
composer require myvendor/pagible-theme-mytheme - Publish the assets:
php artisan vendor:publish --tag=cms-theme - Assign the theme to your pages: open a page in the admin panel and select your theme in the "Theme" field (or set the
themevalue when creating pages programmatically). Pages with an empty theme keep using the default base theme.
That's all — pages using your theme now render with your layout, styles and configurable theme variables, while everything you didn't override transparently falls back to the base theme.
Template Helper Functions
cms(?object $item, ?string $prop, $default = null): This is a central function to retrieve data from CMS items (like pages or elements). It attempts to get a specific property ($prop) from the given$item. It handles null inputs gracefully, returning$defaultif either$itemor$propis null and it supports Laravel Collections too. If the user has 'page:view' permission, it tries to retrieve the property from the latest version, otherwise it returns the value from the published version.cmsasset(?string $path): string: It generates a URL for a static CMS asset (CSS, JavaScript, images, etc.). Internally, it uses theasset()helper to generate the base URL and appends a version string (?v=) based on the file's last modification time. This ensures that browsers load the latest version of the asset.- If
$pathis null or empty, it returns an empty string.
- If
cmsattr(?string $name): string: This function sanitizes a string for use as an HTML attribute (e.g.,idorclass).cmsdata(\Aimeos\Cms\Models\Page $page, object $item): array: To pass data to content element templates, the function returns the content element properties as array and adds afilesobject with the file IDs as keys. If the content element is a shared element or an action, it returns their data as plain array.cmsfile(\Aimeos\Cms\Models\Page $page, string $fileId): ?object: Retrieves a file object from a CMS page by its ID. Returns the file object if found, or null if not found. This is a shortcut forcms(cms($page, 'files'), $fileId).cmsref(\Aimeos\Cms\Models\Page $page, object $item): object: This function resolves references to shared CMS elements. If a referenced element is found, it's returned as an object; otherwise, the original$itemis returned.cmsroute(\Aimeos\Cms\Models\Page $page): string: This function generates the URL to a CMS page or returns the redirect URL of the page. If the user has 'page:view' permission, it uses the latest page version while without, the URL of the published version is returned.cmssrcset($data): string: To generate asrcsetattribute for responsive images, this function expects the value of thepreviewsfile property and returns the correct srcset value.cmstheme(\Aimeos\Cms\Models\Page $page, string $file, bool $version = true): string: Generates the asset URL for a theme file. It resolves the file from the page's theme package and automatically falls back to the base theme if the file does not exist. Likecmsasset(), it appends a version query parameter for cache busting unless$versionis set tofalse. Use this instead ofcmsasset()for theme CSS, JavaScript and image files.cmsurl(?string $path): string: Generates a full URL for a CMS file stored using Laravel's storage system. If the$pathstarts with 'data:', 'http:', or 'https:', it's assumed to be a data URI or an absolute URL and is returned as is. Otherwise, it uses Laravel'sStoragefacade to generate a URL for the file, using the disk configured incms.disk(defaulting to 'public').cmsviews(\Aimeos\Cms\Models\Page $page, object $item): array: Returns the path of the first matching view for rendering a CMS content element based on the page theme and content element type. If no view is found, thecms::invalidtemplate is rendered, which adds a comment to the HTML output that no view was found for the element type.