Development of Custom Kirby Plugin
Plugin in Kirby is PHP package that registers extensions via Kirby::plugin(). Single call can add new field types, blocks, routes, hooks, page methods and more. Plugins distributed via Composer or as folders in site/plugins/.
Plugin Structure
site/plugins/my-plugin/
├── composer.json
├── index.php # entry point
├── lib/ # PHP classes
├── templates/ # block templates
└── assets/
├── css/
└── js/
<?php
// site/plugins/my-plugin/index.php
use Kirby\Cms\App;
use Kirby\Cms\Page;
App::plugin('vendor/my-plugin', [
'options' => [
'cache' => true,
'apiKey' => null,
],
'fields' => require __DIR__ . '/lib/fields.php',
'methods' => require __DIR__ . '/lib/methods.php',
'routes' => require __DIR__ . '/lib/routes.php',
'hooks' => require __DIR__ . '/lib/hooks.php',
'blueprints' => require __DIR__ . '/lib/blueprints.php',
'translations' => [
'ru' => require __DIR__ . '/lib/translations/ru.php',
'en' => require __DIR__ . '/lib/translations/en.php',
],
]);
Custom Page Methods
<?php
// lib/methods.php
return [
'pages' => [
'publishedAndFeatured' => function (): Pages {
return $this->listed()->filter(
fn($p) => $p->featured()->isTrue()
);
},
],
'page' => [
'readingTime' => function (): int {
$words = str_word_count(
strip_tags($this->text()->kirbytext())
);
return max(1, (int) ceil($words / 200));
},
'schemaOrg' => function (): string {
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => $this->title()->value(),
'datePublished' => $this->date()->toDate('c'),
'url' => $this->url(),
];
return '<script type="application/ld+json">'
. json_encode($schema, JSON_UNESCAPED_UNICODE)
. '</script>';
},
],
'file' => [
'isWebOptimized' => function (): bool {
return in_array($this->extension(), ['webp', 'avif', 'svg']);
},
],
];
In templates:
echo $page->readingTime(); // 5
echo $page->schemaOrg(); // <script type="application/ld+json">...</script>
$page->siblings()->publishedAndFeatured();
Custom Routes
<?php
// lib/routes.php
return [
[
'pattern' => 'api/v1/sitemap',
'method' => 'GET',
'action' => function () {
$pages = kirby()->site()->index()->listed()->filter(
fn($p) => $p->intendedTemplate()->name() !== 'error'
);
$urls = $pages->map(fn($p) => [
'url' => $p->url(),
'modified' => $p->modified('Y-m-d'),
'priority' => $p->isHomePage() ? '1.0' : '0.8',
])->values();
return \Kirby\Http\Response::json(['urls' => $urls]);
},
],
[
'pattern' => 'api/v1/search',
'method' => 'GET',
'action' => function () {
$query = get('q', '');
if (strlen($query) < 2) {
return \Kirby\Http\Response::json(['results' => []]);
}
$results = kirby()->site()->search($query, 'title|text')
->listed()
->limit(10)
->map(fn($p) => [
'title' => $p->title()->value(),
'url' => $p->url(),
'excerpt' => $p->text()->excerpt(120),
]);
return \Kirby\Http\Response::json(['results' => $results->values()]);
},
],
];
Hooks
<?php
// lib/hooks.php
return [
'page.create:after' => function (Page $page) {
if ($page->intendedTemplate()->name() !== 'article') {
return;
}
// ping search engines
$url = urlencode(kirby()->site()->url() . '/sitemap.xml');
@file_get_contents("https://www.google.com/ping?sitemap={$url}");
},
'page.update:after' => function (Page $newPage, Page $oldPage) {
// clear cache for specific page
if (kirby()->cache('pages')->exists($newPage->id())) {
kirby()->cache('pages')->remove($newPage->id());
}
},
'file.create:after' => function (\Kirby\Cms\File $file) {
if (!$file->isImage()) {
return;
}
// generate WebP versions preventively
$file->thumb(['width' => 800, 'format' => 'webp']);
$file->thumb(['width' => 400, 'format' => 'webp']);
},
'kirby.render:before' => function (string $template, array $data) {
// can add global data
},
];
Custom Field Type for Panel
Panel fields written in Vue.js and registered with PHP backend:
<?php
// lib/fields.php
return [
'colorpicker' => [
'props' => [
'value' => function ($value = '#000000') {
return $value;
},
'presets' => function (array $presets = []) {
return $presets;
},
],
'save' => function ($value): string {
// validation before save
if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $value)) {
throw new \Exception('Invalid color format');
}
return $value;
},
],
];
// assets/js/fields.js
panel.plugin('vendor/my-plugin', {
fields: {
colorpicker: {
template: `
<k-field v-bind="$props">
<input type="color" :value="value" @input="$emit('input', $event.target.value)">
<div class="presets">
<button
v-for="color in presets"
:key="color"
:style="{ background: color }"
@click="$emit('input', color)"
/>
</div>
</k-field>
`,
props: {
value: String,
presets: Array,
},
},
},
});
Asset registration:
App::plugin('vendor/my-plugin', [
'fields' => require __DIR__ . '/lib/fields.php',
'assets' => [
'js/fields.js' => __DIR__ . '/assets/js/fields.js',
],
]);
Plugin Options
// getting option in plugin code
$apiKey = option('vendor/my-plugin.apiKey');
$useCache = option('vendor/my-plugin.cache', true);
User overrides in site/config/config.php:
return [
'vendor/my-plugin' => [
'apiKey' => 'sk-...',
'cache' => false,
],
];
Development Timelines
Plugin with methods and hooks without UI: 1–3 days. Plugin with custom panel field (Vue + PHP): 3–6 days. Complex plugin with custom panel section, external API and caching: 1–2 weeks.







