Skip to content

From custom sections to fields

Kirby 6 has no sections anymore: everything a section did, a field can do now. This guide shows how to turn a custom section plugin into a field, on the PHP side, in the Panel and in your blueprints.

What changed

Sections and fields used to be two parallel worlds with their own plugin extensions, API routes, Vue mixins and blueprint syntax. In Kirby 6, fields fully replace sections. A field that doesn't store anything (a DisplayField) covers everything a section was used for. And as a field, it also gets when conditions, a width and its place in the same blueprint structure as all other fields.

For the core sections, nothing needs to happen right away: Kirby converts pages, files, info and stats sections into the pagelist, filelist, info and stats fields, and unwraps fields sections into their fields. Read more about the conversion and the new fields in Blueprints.

Custom sections are not converted. You will need act and convert your section into a field. See the full list of breaking changes for sections.

Porting a section

Let's port a realistic section plugin: a "Backlinks" section that lists all pages linking to the current page. Searching the whole site is slow on big sites, so the section loads its data asynchronously, which makes it a good example for loading data in a field as well.

The Kirby 5 section

/site/plugins/backlinks/index.php
<?php

use Kirby\Toolkit\I18n;

Kirby::plugin('acme/backlinks', [
    'sections' => [
        'backlinks' => [
            'props' => [
                /**
                 * Text shown when no page links here
                 */
                'empty' => function ($empty = null) {
                    return I18n::translate($empty, $empty);
                },
                'label' => function ($label = null) {
                    return I18n::translate($label, $label);
                }
            ],
            'computed' => [
                'pages' => function () {
                    $model = $this->model();
                    $uuid  = $model->uuid()?->id();

                    if ($uuid === null) {
                        return [];
                    }

                    $pages = [];

                    foreach ($this->kirby()->site()->index(true) as $page) {
                        if ($page->is($model) === true) {
                            continue;
                        }

                        $content = json_encode($page->content()->toArray());

                        if (str_contains($content, $uuid) === true) {
                            $pages[] = [
                                'info' => $page->id(),
                                'link' => $page->panel()->url(true),
                                'text' => $page->title()->value(),
                            ];
                        }
                    }

                    return $pages;
                }
            ],
            'toArray' => function () {
                return [
                    'empty' => $this->empty ?? 'No page links here yet',
                    'label' => $this->label,
                    'pages' => $this->pages
                ];
            }
        ]
    ]
]);
/site/plugins/backlinks/index.js
panel.plugin("acme/backlinks", {
    sections: {
        backlinks: {
            data() {
                return {
                    empty: null,
                    label: null,
                    pages: []
                };
            },
            async created() {
                const response = await this.load();
                this.empty = response.empty;
                this.label = response.label;
                this.pages = response.pages;
            },
            template: `
                <k-section :label="label" class="k-backlinks-section">
                    <k-collection
                        :empty="{ icon: 'link', text: empty }"
                        :items="pages"
                    />
                </k-section>
            `
        }
    }
});

The Kirby 6 field class

A section stores nothing, so the field extends Kirby\Form\Field\DisplayField. It already brings label and help (both translatable), as well as name, when and width. The rest follows the same rules as for any other custom field, see Custom fields as PHP classes for the details.

/site/plugins/backlinks/classes/BacklinksField.php
<?php

namespace Acme\Backlinks;

use Kirby\Form\Field\DisplayField;
use Kirby\Reflection\Attributes\Prop;

class BacklinksField extends DisplayField
{
    /**
     * Text shown when no page links here
     */
    #[Prop]
    protected array|string|null $empty = null;

    public function api(): array
    {
        $field = $this;

        return [
            [
                'pattern' => '',
                'method'  => 'GET',
                'action'  => fn () => [
                    'pages' => $field->pages()
                ]
            ]
        ];
    }

    public function empty(): string
    {
        return $this->stringTemplateI18n($this->empty) ?? 'No page links here yet';
    }

    /**
     * All pages that mention the UUID of the current model
     */
    public function pages(): array
    {
        $model = $this->model();
        $uuid  = $model->uuid()?->id();

        if ($uuid === null) {
            return [];
        }

        $pages = [];

        foreach ($this->kirby()->site()->index(true) as $page) {
            if ($page->is($model) === true) {
                continue;
            }

            $content = json_encode($page->content()->toArray());

            if (str_contains($content, $uuid) === true) {
                $pages[] = [
                    'info' => $page->id(),
                    'link' => $page->panel()->url(true),
                    'text' => $page->title()->value(),
                ];
            }
        }

        return $pages;
    }
}

Kirby picks up the #[Prop] attribute and sends the empty option to the Panel, through the empty() getter.

Here's where each part of the section went:

Section (Kirby 5) Field (Kirby 6)
'sections' => ['backlinks' => […]] A class that extends DisplayField, registered under fields
props Typed properties with #[Prop] and getters. label and help come from DisplayField.
computed Getter methods, sent with the props or loaded through api()
methods Regular methods
toArray #[Prop] properties and props() for everything the component gets right away
this.load() response An api() route with the pattern ''
api api()
dialogs / drawers dialogs() / drawers()
errors errors()
$this->model(), $this->kirby() Same

The pattern '' mounts the route on the field itself, e.g. at /api/pages/notes+my-note/fields/backlinks. That's what the core pagelist and filelist fields do to refresh their lists.

Sync props or async data?

A section always loaded its data with an extra request. A field gets its props together with the view, without any extra request. Decide per piece of data:

  • Cheap data goes into the props. The label, texts, settings and anything you can compute quickly. Your component gets it right away and it's fresh whenever the view loads.
  • Expensive data goes into an api() route and is loaded by the component after it has been rendered, so the view doesn't wait for it. That's what our backlinks field does with pages().

If your section was cheap all along, you can skip the API route entirely and add the pages to the props:

public function props(): array
{
    return [
        ...parent::props(),
        'pages' => $this->pages(),
    ];
}

Keep in mind that Kirby creates field objects in many places, e.g. for every form, every save and every $page->update() call. Don't compute anything expensive while the field is created, e.g. in a constructor or setter. Getters, props() and your API routes are only called when the Panel actually needs them.

Errors

If your section reported errors, e.g. to keep a draft from being published while something is missing, override errors() in your field and return an array of messages. Kirby collects them together with the errors of all other fields. The pagelist and filelist fields use this for their min and max options.

public function errors(): array
{
    if ($this->isActive() === false) {
        return [];
    }

    if ($this->pages() === []) {
        return ['backlinks' => 'Please link to this page from somewhere first'];
    }

    return [];
}

Registering the field

/site/plugins/backlinks/index.php
<?php

use Acme\Backlinks\BacklinksField;

load([
    'Acme\Backlinks\BacklinksField' => 'classes/BacklinksField.php'
], __DIR__);

Kirby::plugin('acme/backlinks', [
    'fields' => [
        'backlinks' => BacklinksField::class
    ]
]);

The Vue component

The section mixin is gone, and so is this.load(). Your component is now a regular field component:

  • Wrap it in <k-field> instead of <k-section>. <k-field> renders the label and the help text. Set :input="false", as there is no input element the label could point to.
  • Declare the props of your field. They arrive directly, no need to copy a response into data().
  • Replace this.load() with a request to the field endpoint: this.$api.get(this.endpoints.field).
/site/plugins/backlinks/src/components/BacklinksField.vue
<template>
    <k-field
        :help="help"
        :input="false"
        :label="label"
        :name="name"
        :type="type"
        class="k-backlinks-field"
    >
        <k-collection
            :empty="{ icon: 'link', text: empty }"
            :items="pages"
        />
    </k-field>
</template>

<script>
export default {
    inheritAttrs: false,
    props: {
        // from DisplayField
        help: String,
        label: String,
        name: String,
        type: String,
        // from BacklinksField
        empty: String,
        // added by the Panel
        endpoints: Object
    },
    data() {
        return {
            pages: []
        };
    },
    async created() {
        const response = await this.$api.get(this.endpoints.field);
        this.pages = response.pages;

        // lets the preview view adjust the page links,
        // like the core `pagelist` field does
        await this.$nextTick();
        this.$events.emit("field.loaded", this);
    }
};
</script>
/site/plugins/backlinks/src/index.js
import BacklinksField from "./components/BacklinksField.vue";

panel.plugin("acme/backlinks", {
    fields: {
        backlinks: BacklinksField
    }
});

The component is compiled for Vue 3 now. Learn more in Vue 3 for Panel plugins.

Updating blueprints

Everyone who uses your plugin needs to move it from sections to fields in their blueprints. Let them know in your release notes.

Before

/site/blueprints/pages/note.yml
title: Note

columns:
  main:
    width: 2/3
    sections:
      content:
        type: fields
        fields:
          text:
            type: textarea
  sidebar:
    width: 1/3
    sections:
      backlinks:
        type: backlinks
        headline: Linked from

After

/site/blueprints/pages/note.yml
title: Note

columns:
  main:
    width: 2/3
    fields:
      text:
        type: textarea
  sidebar:
    width: 1/3
    fields:
      backlinks:
        type: backlinks
        label: Linked from