Programmable blueprints
Intro
Blueprints for Panel forms are usually static YAML files. While this is totally fine for most use cases, there are some situations where you might wish there was a more dynamic way of creating blueprints.
In a Kirby plugin, we can register blueprints inside the blueprints array as key/value pairs, where the key is the name of the blueprint. As values we can either provide the path to a file or we can assign an array or a callback. While an array is static, the callback approach allows us to create blueprint plugins dynamically without sacrificing performance.
This approach works for complete page/user/file blueprints as well as for partial blueprints like tabs, fieldgroups etc. and opens up many new possibilities.
Dynamic blueprints can be risky if they handle unvalidated or untrusted user data. This recipe assumes that all handled content is trusted.
We don't have access to the current page object in the PHP blueprint files, and therefore have to hard-code the pages we want to fetch via PHP. So the possibilities we have with this type of setup are not endless but useful in certain situations.
Prerequisites
- A running Kirby Starterkit
- The Pages Display section plugin installed, for the page sections example
Let's start with a new plugin folder called programmable-blueprints
in /site/plugins/
. Inside that folder, let's create the obligatory index.php
file, where we will register all blueprints for this recipe.
<?php
use Kirby\Cms\App as Kirby;
Kirby::plugin('cookbook/programmable-blueprints', [
'blueprints' => [
// here we will add the blueprints
]
]);
Load different blueprints per user
With programmable blueprints it is now much easier to load different blueprints per user or user role, which is for example useful if you want to hide fields/sections/tabs from certain users/roles.
Let's assume we had multiple user roles, e.g. the default admin
role and other roles like editor
etc., and we wanted to provide a different site.yml
blueprints for each role.
To achieve this, we create a subfolder /blueprints
with two files site.admin.yml
and site.editor.yml
.
Then we register a site
blueprint and assign the two files to use conditionally:
<?php
use Kirby\Cms\App as Kirby;
Kirby::plugin('cookbook/programmable-blueprints', [
'blueprints' => [
'site' => function () {
if (($user = kirby()->user()) && $user->isAdmin()) {
return Data::read(__DIR__ . '/blueprints/site.admin.yml');
} else {
return Data::read(__DIR__ . '/blueprints/site.editor.yml');
}
},
]
]);
We use Data::read()
to read the yaml
files into an array.
Next we fill the blueprints with some content. For this example, let's keep them simple:
The site.admin.yml
get's two tabs, one to access all pages of the site, the second for general meta settings which should not be editable by non-admin users.
title: Site
tabs:
overview:
label: Page Overview
columns:
- width: 1/2
sections:
albums:
type: pages
label: Photography
parent: kirby.page("photography")
size: small
info: "{{ page.images.count }} image(s)"
layout: cards
template: album
empty: No albums yet
image:
query: page.cover
cover: true
ratio: 3/2
- width: 1/2
sections:
notes:
type: pages
label: Notes
parent: kirby.page("notes")
info: "{{ page.published }}"
template: note
empty: No notes yet
sortBy: date desc
image:
query: page.cover
cover: true
ratio: 3/2
pages:
type: pages
create: default
templates:
- about
- home
- default
settings:
label: Settings
icon: gear
sections:
fields:
type: fields
fields:
metaTitle:
label: Meta Title
type: text
metaDesc:
label: Meta Description
type: text
ogImg:
label: Open Graph Image
type: files
query: site.images
## ... more fields
In the site.editor.yml
file, we don't need tabs and only include the pages overview without the meta data settings:
title: Site
columns:
- width: 1/2
sections:
albums:
type: pages
label: Photography
parent: kirby.page("photography")
size: small
info: "{{ page.images.count }} image(s)"
layout: cards
template: album
empty: No albums yet
image: icon
- width: 1/2
sections:
notes:
type: pages
label: Notes
parent: kirby.page("notes")
info: "{{ page.published }}"
template: note
empty: No notes yet
sortBy: date desc
image:
query: page.cover
cover: true
ratio: 3/2
pages:
type: pages
create: default
templates:
- about
- home
- default
To check if everything works, delete the site.yml
file from /site/blueprints
. Then log in as admin
. Create a new editor
user role as outlined in the docs, create a new user with this role and log in with this user. You should now see the simplified site view.
Page blueprint with filtered pages sections
A typical situation that has come up multiple times in support are dynamic numbers of pages sections that filter pages by category. Since the number of categories is usually not set in stone from the outset but new categories might be added any time, we cannot possibly set up all sections in advance. In such a use case, being able to create those sections dynamically is a big plus.
For this example we register a pages blueprint called notes
in our index.php
and include the notes.php
file which we have yet to create.
<?php
use Kirby\Cms\App as Kirby;
Kirby::plugin('cookbook/programmable-blueprints', [
'blueprints' => [
'pages/notes' => function ($kirby) {
return include __DIR__ . '/blueprints/pages/notes.php';
},
]
]);
Now create a new subfolder pages
in the /blueprints
folder, and inside the pages
folder the notes.php
file with the following code:
<?php
$sections = [];
if ($page = page('notes')) {
// create a new section array for each unique tag plucked from the children pages
foreach ($page->children()->pluck('tags', ',', true) as $tag) {
$sections['section_' . $tag] = [
'type' => 'pagesdisplay',
'label' => 'Pages with tag ' . $tag,
// note the required quotes around the `$tag` variable
'query' => "page.children.filterBy('tags', '". $tag . "', ',')",
];
}
}
// create the array for the page blueprint with two columns
$yaml = [
'title' => 'Notes',
'options' => [
'changeStatus' => false,
'changeSlug' => false
],
'columns' => [
'sidebar' => [
'width' => '1/3',
'sections' => [
'drafts' => [
'type' => 'pages',
'label' => 'Drafts',
'status' => 'drafts',
]
],
],
'main' => [
'width' => '2/3',
'sections' => $sections, // dynamically generated sections from above
]
]
];
return $yaml;
As mentioned in the prerequistes, this example requires the Pages Display plugin.
To use this blueprint, remove the original /site/blueprints/pages/notes.yml
file.
As a result, we will end up with as many sections as we have tags (and in this example, there will be only one page per section, because all tags are only used once). For the screenshot I've reassigned the tags, so that the result looks less silly:

Note that we have set changeSlug
to false
, because the blueprint would stop working if the page was renamed. If you want to keep the option to change the slug, you can work with a page ID that doesn't change instead.
Field group with dynamic fields
The same kind of logic will work for dynamic fields, for example if you wanted to create a number of pages fields where users can select one (or more) pages from each parent.
Again, we first register the new blueprint:
<?php
use Kirby\Cms\App as Kirby;
Kirby::plugin('cookbook/programmable-blueprints', [
'blueprints' => [
'pages/notes' => function($kirby) {
return include __DIR__ . '/blueprints/pages/notes.php';
},
'fields/multifields' => function($kirby) {
return include __DIR__ . '/blueprints/fields/multifields.php';
}
],
]);
And then create a fieldgroup called multiselects
in the given path:
<?php
$fields = [];
foreach (site()->children()->filterBy('template', 'in', ['notes', 'photography']) as $page) {
$fields[$page->slug()] = [
'label' => $page->title()->value(),
'type' => 'pages',
'query' => 'site.find("' . $page->slug() . '").children',
'min' => 1,
'max' => 1,
];
}
return [
'type' => 'group',
'fields' => $fields,
];
In a page/user/file blueprint, we can now use this field group like this:
fields:
type: fields
fields:
extends: fields/multifields

Multilang field options
When you use language keys in your blueprints to translate field labels etc., these translations are shown based on the selected user language, not based on the currently selected content language.
This example from the docs…
fields:
category:
label:
en: Category
de: Kategorie
type: select
options:
architecture:
en: Architecture
de: Architektur
photography:
en: Photography
de: Fotografie
design:
en: Design
de: Design
will therefore not switch to the German translation when we switch the content language to German, but when a user selects German as their interface language.
But often, users expect to see the translated option labels when they switch the content language. So, how can we achieve this?
Let's register a new field blueprint in our index.php
:
Kirby::plugin('cookbook/programmable-blueprints', [
'blueprints' => [
'pages/notes' => function($kirby) {
return include __DIR__ . '/blueprints/pages/notes.php';
},
'fields/multifields' => function($kirby) {
return include __DIR__ . '/blueprints/fields/multifields.php';
},
'fields/category' => function($kirby) {
return include __DIR__ . '/blueprints/fields/category.php';
},
],
]);
Then we create a new file category.php
in the corresponding folder with the following code:
<?php
use Kirby\Toolkit\I18n;
return [
'label' => [
'en' => 'Category,
'de' => 'Kategorie'
],
'options' => [
'architecture' => I18n::translate('architecture', null, kirby()->language()->code()),
'photography' => I18n::translate('photography', null, kirby()->language()->code()),
'design' => I18n::translate('design', null, kirby()->language()->code())
],
'type' => 'select',
];
In our page blueprint, we can now replace the category field definition:
fields:
category:
extends: fields/category
Assigning filtered blueprints to sections
When you want to assign allowed templates to a pages section, you have to list them out one by one. Not with our programmatic approach.
For this example, we register a new section blueprint:
Kirby::plugin('cookbook/programmable-blueprints', [
'blueprints' => [
'pages/notes' => function($kirby) {
return include __DIR__ . '/blueprints/pages/notes.php';
},
'fields/multifields' => function($kirby) {
return include __DIR__ . '/blueprints/fields/multifields.php';
},
'fields/categories' => function($kirby) {
return include __DIR__ . '/blueprints/fields/categories.php';
},
'sections/notes' => function($kirby) {
return include __DIR__ . '/blueprints/sections/notes.php';
},
],
]);
And create /blueprints/sections/notes.php
with the following code:
<?php
$blueprints = kirby()->blueprints();
$blueprints = array_filter($blueprints, function($blueprint) {
return in_array($blueprint, ['error', 'home', 'about']) === false;
});
return [
'type' => 'pages',
'parent' => "kirby.page('notes')",
'label' => 'Notepages',
'info' => '{{ page.files.count }}',
'templates' => $blueprints,
];
First we fetch all registered page blueprints into $blueprints
and then remove all the unwanted ones from the array. The remaining blueprints we assign to the section's templates
prop.
We can now reuse this section in our templates like normal.
Title: Notes
#...
sections:
drafts:
extends: sections/notes
listed:
extends: sections/notes
If we need the same set of blueprints for multiple sections, we can return the filtered set from its own blueprint and include it in our PHP pages/sections blueprints:
From blueprints.php
we now only return an array of blueprints:
<?php
$blueprints = kirby()->blueprints();
return array_filter($blueprints, function($blueprint) {
return in_array($blueprint, ['error', 'home', 'about']) === false;
});
And load this array for example in the notes.php
section:
return [
'type' => 'pages',
'parent' => "kirby.page('notes')",
'label' => 'Notepages',
'info' => '{{ page.files.count }}',
'templates' => include __DIR__ . '/../options/blueprints.php',
];
We don't have to register options/blueprints.php
, because we cannot use it like a normal extension but have to include it explicitly.
Tabs
In this last example, we register a tab blueprint.
<?php
use Kirby\Cms\App as Kirby;
Kirby::plugin('cookbook/programmable-blueprints', [
'blueprints' => [
'pages/notes' => function($kirby) {
return include __DIR__ . '/blueprints/pages/notes.php';
},
'fields/multifields' => function($kirby) {
return include __DIR__ . '/blueprints/fields/multifields.php';
},
'fields/categories' => function($kirby) {
return include __DIR__ . '/blueprints/fields/categories.php';
},
'sections/notes' => function($kirby) {
return include __DIR__ . '/blueprints/sections/notes.php';
},
'tabs/meta' => function($kirby) {
return include __DIR__ . '/blueprints/tabs/meta.php';
},
],
]);
Let's assume we already had a basic yml
blueprint that we wanted to extend programmatically.
This is our basic blueprint, which we put into the blueprints/tabs
folder for simplicity.
label: Meta
icon: search
sections:
basicMeta:
type: fields
fields:
metaHeadline:
label: Basic Meta Information
type: headline
numbered: false
metaTitle:
label: Title (Override)
type: text
metaDescription:
label: Description
type: text
metaCanonicalUrl:
label: Canonical URL
type: url
metaAuthor:
label: Author/s
type: text
metaImage:
label: Image
type: files
multiple: false
metaPhoneNumber:
label: Phone Number
type: text
How can we extend this via PHP? In the same folder, let's create the meta.php
file we already registered above, and add the following code:
<?php
use Kirby\Data\Data;
$basicBlueprint = Data::read(__DIR__ . '/seo-basic.yml', 'yaml');
$basicBlueprint['label'] = 'SEO';
$fields = $basicBlueprint['sections']['basicMeta']['fields'];
$basicBlueprint['sections']['basicMeta']['fields'] = array_merge($fields, [
'twitterHandle' => [
'label' => 'Twitter handle',
'type' => 'text',
]
]);
return $basic_blueprint;
First, we read the file we want to extend into an array with Data::read()
.
Then we change the tab label and add a new field in the basicMeta
section by merging the original fields array with the new twitterHandle
field.
We can now add this tab in our page blueprints:
title: Some page blueprint
tabs:
tab1:
label: Content
# code for tab1 here
tab2: tabs/meta
If you want to add the new field at another position in the array, or remove another item from the original field list, you can achieve this using PHP's array functions.
Recap
In this recipe we looked into creating different types of blueprints dynamically, which can be helpful in several use cases. And if you don't like YAML
, it might even become your favorite way of creating blueprints. Despite some limitations, you can still get pretty creative with this approach.
If you have other great ideas how to use this feature, let us know.