Skip to content

Back to Toolbox

Notes on Kirby CMS

Some initial notes, as I learn my way around Kirby CMS.

Updating Kirby

One way to update Kirby for a project: delete the kirby folder and re-fetch it from GitHub using degit. Below we upgrade to Kirby 3.9.6:

rm -rf media && rm -rf kirby && npx degit getkirby/kirby#3.9.6 kirby

macOS local environment

For local development, Kirby works well with PHP’s built-in server.

brew install php
php -S localhost:8000 kirby/router.php

See: Local development environment.


A secure Kirby deploy must prevent HTTP access to some of its files and folders, as outlined in the Security guide.

Both distributions (Plainkit and Starterkit) come wih an .htaccess file which enforces these rules for Apache and compatible servers. There is also a recipe for running Running Kirby on a Nginx web server.

Content modeling

Site content exists as a plain-text file system hierarchy inside the /content/ folder. Each page in the website gets a folder with a main content file, plus any images or other supporting files.

Content files are a loose collection of key-value pairs called Fields. Any field defined in a content file will be available to templates.

Content can be managed with a text editor, as you would for a static site generator such as Eleventy or Hugo, but also with Kirby’s admin interface, called the Panel.

To create and manage content visually, we need to define schemas for our content types with Blueprints. All blueprints are stored in the site/blueprints/ folder. You can have blueprints for: pages, files, users, and the site (the Panel dashboard homepage). Additionally, you can make reusable bits for fields (site/blueprints/fields/) and sections (site/blueprints/sections/).

A page blueprint lists the fields, along with their type, as well as the layout for presenting them ot the user.

Content created and edited with the Panel reflects directly to files inside the /content/ folder.


Kirby uses plain PHP as the templating language but provides a neat, object-oriented API that makes templates easy enough to read.

Templating is pretty lean: a content file named project.txt will use the /site/templates/project.php template file, if it exists. Other than defaulting to /site/templates/default.php, there’s no other prescribed template hierarchy as with WordPress.

The mechanism for template reuse is snippets with slots, which is enough to stand in for Twig’s extend and include tags. Speaking of which: Twig is available as a separate plugin, along with other templating languages, should you find plain PHP unpalatable.

To prevent XSS vulnerabilities, values in templates can be escaped with:

These methods accept a $context which lets you target different escaping contexts.

Folder structure and deployment flow

The default Kirby folder structure may not be ideal for some workflows and can be changed to better delineate environment-specific folders managed by Kirby.

It seems common to move the Kirby-generated /site/accounts/, /site/sessions/ and /site/cache/ into a separate /storage/ folder, and everything web-accessible to the /public/ folder.

The code goes into public/index.php:


$base = dirname(__DIR__);

include $base . '/kirby/vendor/autoload.php';

$kirby = new Kirby([
'roots' => [
'index' => $base . '/public',
'content' => $base . '/content',
'site' => $base . '/site',
'accounts' => $base . '/storage/accounts',
'cache' => $base . '/storage/cache',
'sessions' => $base . '/storage/sessions',
'logs' => $base . '/storage/logs'

echo $kirby->render();

To start the local PHP server with /public/ as the root folder, use:

php -S localhost:8000 -t public/ kirby/router.php

When changing the folder structure, don’t forget to update .htaccess (or equivalent configuration file) to keep your Kirby installation secure.

Developing custom interfaces

Custom interfaces for the Panel are defined in Plugins, which tend to have two parts:

The front-end generally interacts with the back-end via the Kirby (REST) API.

A custom Panel section

In addition to working with the REST API, you can fetch data for a custom Panel section in the plugin’s index.php file with computed properties.

This allows the user to configure the panel section when using it in a blueprint.

For example, you could have a query property in the section that gets interpreted as a query string, just like some built-in fields work, which you can use to drill down into arrays/objects with Kirby\Toolkit\Str’s query method:

use Throwable;

try {
return Str::query($query, [
// data
} catch(Throwable) {
return null;

Note: the try/catch block is necessary because any null within the chain will cause the query to throw. [TODO: is that really the case?]

Loading JavaScript and CSS

On the front-end

In your PHP template files, you can reference JS and CSS from the site’s assets/ folder. The js() and css() helpers will produce the <script> and <link> markup for you.

Additionally, anything placed in a plugin’s own assets/ folder will be made publicly available. The public path for site/plugins/<plugin-folder>/assets/<file> is media/plugins/plugin-author/plugin-name/<file>.

Note the usage of plugin-author/plugin-name as defined in the plugin’s index.php rather than the plugin’s physical path, and the absence of assets/ in the public path.

The canonical way to access plugin asset paths in templates seems to be:

<?= $kirby->plugin('plugin-author/plugin-name')->mediaUrl() . '/<file>' ?>

See Plugin Basics: Plugin assets.

Note: at the moment you can’t use the .mjs extension for plugin assets that are ES modules [Kirby#5463]. Use the .js extension instead.

Everywhere in the Panel

Kirby can load one custom JavaScript file and one custom CSS file with the panel.js and panel.css settings, respectively.

The custom JS file is loaded as an ES module, so you can import further ES modules with it. To include traditional scripts in the page, you must use DOM methods.

For a specific Panel plugin

Each plugin has the following files interpreted:

All the index.js files from all plugins are concatenated into a single file, loaded as a non-module <script>. Therefore all code in these files is in the global scope, opening it to the possibility of name collisions.

To import further JS files, you’ll need to use a bundler such as esbuild, configured to produce bundles in the IIFE format, for best encapsulation.

Working with dates, times, and timezones

For a quick overview of the various date & time formats, see A Venn diagram of date and time formats, according to RFC 3339 vs. ISO 8601 vs. HTML.

Kirby’s Date and Time fields don’t store the timezone information in content files. A typical date-time string looks like this, which corresponds to the "Y-m-d H:i:s" PHP format string.

Start-date: 2023-08-28 09:00:00

When used in PHP date objects, the server timezone is assumed by default. We can declare an explicit timezone as the first thing in Kirby’s index.php file:


To send dates to the client, including the timezone we’ve set up, to be parsed as JavaScript date objects we use the DateTimeInterface::W3C format, an alias for "Y-m-d\\TH:i:sP".

new DateTimeImmutable($date_string)->format(DateTimeInterface::W3C);

In the opposite direction, we want to accept dates from the client in the format produced by date.toISOString(), which is always in explicit UTC, denoted by the suffix Z. A typical date-time string looks like this:

"start_date": "2023-09-16T16:26:38.428Z"

When Kirby prepares a date-time string for storage, the timezone seems to be ignored, resulting in the incorrect date to be stored. To work around this, we need to convert from UTC to the server timezone, then format it as Kirby would when it saves the date to the content file.

function toKirbyDate($date_string) {
return (new DateTimeImmutable($date_string))
->setTimezone(new DateTimeZone(date_default_timezone_get()))
->format("Y-m-d H:i:s");

You probably want locale-aware sorting

By default Kirby ignores locale rules when sorting things. In Romanian, this manifests in letters with diacritical marks (Ă, Î, etc.) being shifted to the end of the alphabet.

A Pages section’s sortBy property accepts the SORT_LOCALE_STRING keyword to enable locale-aware sorting, and so does the $pages->sortBy() method.

Building Panel plugins with esbuild and npm workspaces

Due to the various constraints for including JavaScript and CSS for plugins on the Panel side of things, it’s often easier to just bundle plugin code in a single JS file.

There’s PluginKit (which uses kirbyup) for the purpose, but I’ve sought a lightweight solution with as few dependencies as possible.

Initially I wanted to use a top-level esbuild command to build all plugins at once, but there’s a missing piece in the API that makes it hard to output files from site/plugins/my-plugin/src one level up to the plugin’s root folder.

Instead, I moved the esbuild commands to each plugin’s package.json.

I learned about npm workspaces. Usually meant to manage many packages from a single root folder, it has a bunch of extraneous features (symlinking packages in node_modules) but it allows us to run a command across all packages at once.

Start by declaring the plugin folders as workspaces in the top-level package.json, and forwarding any scripts you want by using npm run --workspaces, which runs the corresponding script in all workspaces:

"workspaces": [
"scripts": {
"build": "npm run build --workspaces --if-present",
"watch": "npm run watch --workspaces --if-present"

Adding the --if-present flag prevents npm run from throwing an error if one of the workspaces is missing a script.

Inside each plugin’s folder, we declare the scripts in package.json:

"scripts": {
"build": "npx esbuild --format=iife --bundle _src/index.js _src/assets/index.js --outdir=.",
"watch": "npm run build -- --watch"

I’m bundling as IIFE (immediately invoked function expression), because Kirby concatenates the output of all plugin index.js files in a single JS file.

In the top level, npm run build runs all build scripts for plugins.

However, npm run watch only watches the files for the first plugin. That’s because npm run --workspaces runs workspace scripts in sequence, and esbuild --watch prevents subsequent commands from running.

npm doesn’t support running things in parallel (npm/rfcs#190), and the packages that provide that — the popular npm-run-all and its successor npm-run-all2 — don’t have a --workspaces option.

In the interim, I do this:

"scripts": {
"watch": "run-p watch:*",
"watch:plugin1": "npm run watch --workspace=site/plugins/plugin1",
"watch:plugin2": "npm run watch --workspace=site/plugins/plugin2"

API route permissions for users without Panel access

Users with Panel access disabled (via permissions.access.panel: false in the user blueprint) generally don’t have access to routes under /api/, except for a few endpoints related to /api/auth to allow them to log in and out.

Kirby doesn’t seem to support per-route permissions (this suggestion may be related). To allow specific API endpoints for non-Panel users, we need to roll our own check:

'api' => [
'routes' => [
'pattern' => 'my-route',
'method' => 'GET',
'auth' => false,
'action' => function() {
// Note: You can be fancy and return HTTP 401 / 403 instead.
if (!kirby()->user() || !csrf(kirby()->request()->csrf())) {
return null;
return $your_thing;

To add a log out link to the front-end:

<a href='/panel/logout'>Log out</a>

Easy Panel shortcuts for the front-end

<?php if($user->role()->permissions()->for('access', 'panel')): ?>
<a href='<?= $page->panel()->url() ?>?language=<?= $kirby->language() ?>'>
<?= t('Edit page') ?>
<a href='<?= $site->panel()->url() ?>?language=<?= $kirby->language() ?>'>
<?= t('Admin panel') ?>
<?php endif; ?>

Quirks and workarounds

Filling in Panel fields with JavaScript

In a page edit screen, I needed to fill in a whole set of fields with JavaScript. Kirby uses Vue 2.7 for the Panel front-end, and it would not pick up correctly on the external DOM updates.

Without looking too much into it, a workaround is to emit an input event and pause for 100ms before moving to the next field.

Instead of:

function populateFields() {
const FIELD_NAMES = ['headline', 'excerpt', 'description'];
for (const name of FIELD_NAMES) {
const input = document.querySelector(`[name=${name}]`);
inputEl.value = 'some-value';

You can do this:

async function populateFields() {
const FIELD_NAMES = ['headline', 'excerpt', 'description'];
for await (const name of FIELD_NAMES) {
const input = document.querySelector(`[name=${name}]`);
inputEl.value = 'some-value';
inputEl.dispatchEvent(new Event('input'));
await new Promise(r => setTimeout(r, 100));

Limitations I’ve bumped into

Further reading