Custom fields as PHP classes
In Kirby 6, every Panel field is a PHP class. Array-based field definitions are gone. This guide walks you through porting a custom field from Kirby 5, from the class to the Vue component.
Why classes?
In Kirby 5, a custom field could be an array of props, computed and methods closures, a Kirby\Form\FieldClass subclass, or a mix of both through extends. Back in the day we thought this would be a great way to mirror the Vue component structure. But turned out, it wasn't that great. Every prop and computed closure ran for every field instance, nothing could be typed or tested in isolation, and your IDE had no idea what $this was. Kirby 6 replaces all of this with a single class hierarchy: every field extends Kirby\Form\Field, blueprint options become typed properties with the #[Prop] attribute, and anything costly is resolved lazily in a getter. Your field gets real types and real inheritance, and it is built from the same parts as the core fields.
Pick a base class
Start with the base class that matches what your field does. They all live in the Kirby\Form\Field namespace:
| Base class | Use it for | Blueprint options it adds | Core examples |
|---|---|---|---|
DisplayField |
Fields that show something but store nothing – this is what custom sections become | help, label |
headline, info, stats, buttons |
ValueField |
Fields that hold a value but offer no input for it | – | hidden |
InputField |
Everything editors fill in | autofocus, default, disabled, help, label, required, translate |
number, toggle, structure, blocks |
Most custom fields extend InputField or DisplayField. For more specialized fields, Kirby has further abstract base classes you can build on:
StringField(extendsInputField): string values withmaxlength,minlength,placeholderandspellcheck, e.g.textandwriterOptionField/OptionsField(extendInputField): one or several values fromoptions, e.g.selectorcheckboxesDateTimeField(extendsInputField):dateandtime
To build on top of a core field, extend its concrete class, e.g. Kirby\Form\Field\TextField. See Extending a core field below.
The base class also decides whether your field stores anything: Field::hasValue() is final now and returns true for every subclass of ValueField. The old save: false option is simply the choice of DisplayField over InputField.
Mixins
Many props that core fields share are available as traits in Kirby\Form\Mixin, e.g. After, Before, EmptyState, Icon, Max, Min, Options or Placeholder. Each trait brings the property with its docblock and #[Prop] attribute, as well as its getter. Using the trait is all it takes:
<?php
namespace Acme\MyField;
use Kirby\Form\Field\InputField;
use Kirby\Form\Mixin;
class MyField extends InputField
{
use Mixin\Icon;
protected string $value = '';
}
Your field now accepts the icon option in blueprints and sends it to your Vue component.
Registering a field
The registration looks just like the one for class-based fields in Kirby 5: the key is the field type for your blueprints, the value is the class name.
<?php
use Acme\Rating\RatingField;
load([
'Acme\Rating\RatingField' => 'classes/RatingField.php'
], __DIR__);
Kirby::plugin('acme/rating', [
'fields' => [
'rating' => RatingField::class
]
]);
Kirby only needs the class when a blueprint actually uses the field type, so any autoloader works. For simple plugins, the load() helper shown above is enough. If your plugin is installed with Composer, a PSR-4 autoload entry in its composer.json does the job as well.
The key you register the field with is its type. Kirby sends it to the Panel as type prop, so it also decides which Vue component renders the field: rating → <k-rating-field>. This also lets you register an existing class under a new name, e.g. to render a core field with your own component:
Kirby::plugin('acme/notes', [
'fields' => [
'note' => Kirby\Form\Field\InfoField::class
]
]);
The field reports note as its type and the Panel renders it with your <k-note-field> component.
Props
Blueprint options are properties with #[Prop]
Each option of your field is a typed property with the Kirby\Reflection\Attributes\Prop attribute. The default value of the property is the default of the option, and its docblock describes the option:
<?php
namespace Acme\Rating;
use Kirby\Form\Field\InputField;
use Kirby\Reflection\Attributes\Prop;
class RatingField extends InputField
{
/**
* Number of stars
*/
#[Prop]
protected int $max = 5;
protected int $value = 0;
}
Properties without the attribute, like $value, are not options and can't be set from a blueprint.
When Kirby creates a field from a blueprint, it passes all options of the field to its constructor:
fields:
rating:
type: rating
label: Rating
max: 10
// what Kirby does for you
$field = new RatingField(
model: $page,
siblings: $fields,
label: 'Rating',
max: 10,
name: 'rating',
type: 'rating',
value: '7'
);
The constructor of Kirby\Form\Field stores every option that has a matching #[Prop] property:
- Options without a matching property are ignored.
nullkeeps the default of the property. That's why every prop needs a default, even if it's just= null.- Associative arrays are merged into the default of the property. Lists replace it.
- An option with the wrong type stops with a clear error message, e.g.
Field "rating": The option "max" must be of type int.
The value is filled last, after all other options have been stored (more on that below).
Your own constructor
Your field doesn't need a constructor to accept options. If you still need one, e.g. to inject a service, accept all other options as mixed ...$args and pass them on to the parent constructor.
class MapField extends InputField
{
public function __construct(
protected MapService $service = new MapService(),
mixed ...$args
) {
parent::__construct(...$args);
}
}
Store props as passed, resolve them in getters
Kirby stores each option as it was passed and the getter does the work: defaults, translations, query templates. This keeps the original values accessible and makes creating a field cheap. This matters, because Kirby creates field objects for every form, every save and every $page->update() call.
- Defaults live on the property:
protected int $max = 5;. The default is used whenever the option is missing ornull. - Translations and templates are resolved in the getter:
/**
* Text shown as long as nothing has been rated
*/
#[Prop]
protected array|string|null $empty = null;
public function empty(): string|null
{
// translates `{ en: …, de: … }` arrays and
// resolves `{{ page.title }}` placeholders
return $this->stringTemplateI18n($this->empty);
}
The helpers you know from Kirby 5 are available on every field: $this->i18n() translates an option, $this->stringTemplate() resolves query templates and $this->stringTemplateI18n() does both. Getters can rely on $this->model(), $this->kirby(), $this->name() and $this->siblings().
If a prop should render as HTML in the Panel, return a Kirby\Toolkit\HtmlString from the getter, like the core help() does. Plain strings are escaped. Learn more in Trusted HTML.
If an option has to be converted or normalized before it's stored, name a setter in the attribute. The setter receives the value and stores it. It can be protected and the types of its parameter are the types the option accepts. The core date and time fields use this for their step option:
#[Prop(setter: 'setStep')]
protected array $step = [];
protected function setStep(array|int|string $step): void
{
$this->step = Date::stepConfig($step, $this->step);
}
Use setters sparingly. In most cases, the getter is the better place for this kind of work.
Sending props to the Panel
All #[Prop] properties are sent to your Vue component automatically. Kirby reads each value through the public method with the same name as the property, e.g. max() for $max. Without such a method, the property value is sent as it is.
The attribute controls how a prop is set, exported and documented:
| Attribute | Effect |
|---|---|
#[Prop(export: false)] |
Keeps the option in the backend, e.g. if only toStoredValue() needs it |
#[Prop(export: 'name')] |
Sends the option to the Panel under a different name |
#[Prop(getter: 'isLarge')] |
Reads the value through another method, which can also be protected |
#[Prop(setter: 'setStep')] |
Passes the value through this method when it's set |
#[Prop(computed: true)] |
Documents that the final value is computed at runtime |
#[Prop(default: 'auto')] |
Documents a default value that the property itself can't hold |
Override props() only to add values that aren't options. Merge them with the props of the parent class:
public function props(): array
{
return [
...parent::props(),
'total' => $this->entries()->count()
];
}
Two things are not part of the props:
- The value. The Panel gets all values of the view separately. Your component receives its value as the
valueprop anyway. - The default value. It's only included where Kirby needs it, e.g. for new structure entries.
Defaults from the config
Override ::config() to read additional options from Kirby's config. Options from the blueprint take precedence:
public function config(): array
{
return $this->kirby()->option('acme.rating', []);
}
return [
'acme.rating' => [
'icon' => 'heart'
]
];
Model and siblings
The model (page, file, user or site) and the sibling fields are passed to the constructor as model and siblings. They are set before all other options, so config() and setters can already use $this->model().
Creating fields in PHP
To create a field yourself, resolve the class of a field type with Field::resolve() and pass the options as named arguments:
use Kirby\Form\Field;
$class = Field::resolve('rating');
$field = new $class(
model: $page,
name: 'rating',
max: 10,
value: 7
);
Values
Fields with a value extend ValueField (usually through InputField) and must declare a typed $value property with a default. That default is the field's empty value.
protected int $value = 0;
The value passes through a few methods, which you can override:
| Method | Called when | Default behavior |
|---|---|---|
fill(mixed $value) |
The form is filled with stored content (strings from the content file) or with values from PHP | Sets $this->value, or the empty value for null |
submit(mixed $value) |
Editors submit the form in the Panel, or $page->update() is called |
Calls fill() |
toFormValue() |
The Panel needs the value for the value prop of your component |
Returns $this->value |
toStoredValue() |
The value is written to the content file | Returns toFormValue() |
isEmptyValue(mixed $value) |
Kirby checks whether the field is empty, e.g. for required or to apply the default |
null, '' and [] are empty |
A typical save in the Panel goes like this: Kirby fills the form with the stored content, submits the editor's input on top and writes toStoredValues() to the content file. So fill() should cope with both: the string from the content file and the typed value from the Panel.
Validation
InputField brings validation. required is checked automatically, everything else goes into ::validations(). Its array can contain the names of predefined validations from Kirby\Form\Validations (boolean, date, email, max, maxlength, min, minlength, option, options, pattern, time, url), as well as closures with a key of your choice. Closures are bound to the field and receive the current form value. Throw any exception to report an error:
protected function validations(): array
{
return [
'rating' => function (int $value): void {
if ($value < 0 || $value > $this->max()) {
throw new InvalidArgumentException(
message: 'Please pick between 1 and ' . $this->max() . ' stars'
);
}
}
];
}
API routes
API routes go into ::api(), which replaces both the api closure of array fields and routes() of Kirby 5 field classes. The routes are mounted below the field, e.g. at /api/pages/blog+my-article/fields/rating/average. As before, $this inside an action is the API object, so either capture the field beforehand or use $this->field():
public function api(): array
{
$field = $this;
return [
[
'pattern' => 'average',
'method' => 'GET',
'action' => fn () => [
'average' => $field->average()
]
]
];
}
In your Vue component, the endpoints.field prop holds the path to the field. It's set for fields in model views and for fields nested in structure and object fields:
const response = await this.$api.get(this.endpoints.field + "/average");
Dialogs and drawers
Override dialogs() and drawers() to add Panel dialogs or drawers to your field. Each entry is either a closure that returns the dialog (or a dialog controller), or an array with load and submit handlers:
use Kirby\Panel\Ui\Dialog\TextDialog;
public function dialogs(): array
{
return [
'explain' => fn () => new TextDialog(
text: 'Rate the article from 1 to ' . $this->max() . ' stars.'
),
'reset' => [
'load' => fn () => [
'component' => 'k-remove-dialog',
'props' => ['text' => 'Do you really want to reset the rating?']
],
'submit' => fn () => true
]
];
}
Open them from your component with the field endpoint:
this.$panel.dialog.open(this.endpoints.field + "/explain");
The new Panel UI classes like TextDialog and the dialog controllers are covered in Panel backend.
Walkthrough: a rating field
Let's port a complete custom field: a star rating with a configurable number of stars, an empty-state text, validation, a custom storage format and an API route that returns the average rating of all sibling pages.
Kirby 5
<?php
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\I18n;
Kirby::plugin('acme/rating', [
'fields' => [
'rating' => [
'props' => [
/**
* Text shown as long as nothing has been rated
*/
'empty' => function ($empty = null) {
return I18n::translate($empty, $empty);
},
/**
* Icon for each star
*/
'icon' => function (string $icon = 'star') {
return $icon;
},
/**
* Number of stars
*/
'max' => function (int $max = 5) {
return $max;
},
'value' => function ($value = null) {
return is_numeric($value) ? (int)$value : 0;
}
],
'computed' => [
'empty' => function () {
if ($this->empty !== null) {
return $this->model()->toSafeString($this->empty);
}
}
],
'methods' => [
'average' => function () {
$ratings = [];
foreach ($this->model()->siblings(false) as $sibling) {
$rating = $sibling->content()->get($this->name())->toInt();
if ($rating > 0) {
$ratings[] = $rating;
}
}
if ($ratings === []) {
return null;
}
return round(array_sum($ratings) / count($ratings), 1);
}
],
'isEmpty' => function ($value) {
return empty($value);
},
'validations' => [
'rating' => function ($value) {
if ($value < 0 || $value > $this->max) {
throw new InvalidArgumentException(
message: 'Please pick between 1 and ' . $this->max . ' stars'
);
}
}
],
'save' => function ($value) {
return $value > 0 ? $value : null;
},
'api' => function () {
return [
[
'pattern' => 'average',
'action' => function () {
return [
'average' => $this->field()->average()
];
}
]
];
}
]
]
]);
Kirby 6: the class
Each key of the array finds its place in the class:
| Kirby 5 | Kirby 6 |
|---|---|
props |
Typed properties with #[Prop] and getters |
computed |
Getters |
methods |
Regular methods |
props.value |
fill() |
isEmpty |
isEmptyValue() |
validations |
validations() |
save |
toStoredValue() (save: false → extend DisplayField) |
api |
api() |
dialogs / drawers |
dialogs() / drawers() |
extends: text |
extends TextField |
mixins |
Traits from Kirby\Form\Mixin |
<?php
namespace Acme\Rating;
use Kirby\Cms\Page;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Form\Field\InputField;
use Kirby\Reflection\Attributes\Prop;
class RatingField extends InputField
{
/**
* Text shown as long as nothing has been rated
*/
#[Prop]
protected array|string|null $empty = null;
/**
* Icon for each star
*/
#[Prop]
protected string $icon = 'star';
/**
* Number of stars
*/
#[Prop]
protected int $max = 5;
/**
* `0` means "not rated yet". The default of the
* typed `$value` property is the field's empty value.
*/
protected int $value = 0;
public function api(): array
{
$field = $this;
return [
[
'pattern' => 'average',
'method' => 'GET',
'action' => fn () => [
'average' => $field->average()
]
]
];
}
/**
* Average rating of all sibling pages
*/
public function average(): float|null
{
$model = $this->model();
if ($model instanceof Page === false) {
return null;
}
$ratings = [];
foreach ($model->siblings(false) as $sibling) {
$rating = $sibling->content()->get($this->name())->toInt();
if ($rating > 0) {
$ratings[] = $rating;
}
}
if ($ratings === []) {
return null;
}
return round(array_sum($ratings) / count($ratings), 1);
}
public function empty(): string|null
{
// translates `{ en: …, de: … }` arrays and
// resolves `{{ page.title }}` placeholders
return $this->stringTemplateI18n($this->empty);
}
public function fill(mixed $value): static
{
// content files store strings, the Panel sends numbers
return parent::fill(
value: is_numeric($value) ? (int)$value : 0
);
}
public function isEmptyValue(mixed $value = null): bool
{
// `required: true` must also catch `0`
return empty($value);
}
public function max(): int
{
return $this->max;
}
public function toStoredValue(): int|null
{
// keep unrated pages free of `Rating: 0`
return $this->value > 0 ? $this->value : null;
}
protected function validations(): array
{
return [
'rating' => function (int $value): void {
if ($value < 0 || $value > $this->max()) {
throw new InvalidArgumentException(
message: 'Please pick between 1 and ' . $this->max() . ' stars'
);
}
}
];
}
}
A few things to note:
- There's no constructor and no
props()method. Kirby stores the options and sendsempty,iconandmaxto the Panel on its own. icondoesn't even need a getter. Without one, Kirby sends the property value as it is.- The
emptyprop no longer needs a prop closure and a computed value:stringTemplateI18n()translates and resolves the template in one go, and only when the prop is actually needed. label,help,required,disabled,default,width,whenand all other common options are inherited fromInputField.average()is only called by the API route. Expensive work belongs in methods like this one, not in the getters of props, which run every time the Panel loads the field.
Registering the field
<?php
use Acme\Rating\RatingField;
load([
'Acme\Rating\RatingField' => 'classes/RatingField.php'
], __DIR__);
Kirby::plugin('acme/rating', [
'fields' => [
'rating' => RatingField::class
]
]);
The Vue component
The Panel side hardly changes. Your component still receives the value as value prop and reports changes with an input event. Compile it for Vue 3 and declare the event in emits. Learn more about the switch in Vue 3 for Panel plugins.
<template>
<k-field v-bind="$props" class="k-rating-field">
<div class="k-rating-field-stars">
<k-button
v-for="star in max"
:key="star"
:disabled="disabled"
:icon="icon"
:theme="star <= value ? 'yellow-icon' : null"
:title="star + ' / ' + max"
@click="select(star)"
/>
</div>
<p v-if="value === 0 && empty" class="k-help">{{ empty }}</p>
<p v-if="average" class="k-help">Average of all siblings: {{ average }}</p>
</k-field>
</template>
<script>
export default {
inheritAttrs: false,
props: {
// from InputField
disabled: Boolean,
help: String,
label: String,
name: String,
required: Boolean,
type: String,
// from RatingField
empty: String,
icon: String,
max: Number,
// added by the Panel
endpoints: Object,
hasDiff: Boolean,
value: {
type: Number,
default: 0
}
},
emits: ["input"],
data() {
return {
average: null
};
},
async created() {
// fields in dialogs have no endpoints
if (this.endpoints) {
const response = await this.$api.get(this.endpoints.field + "/average");
this.average = response.average;
}
},
methods: {
select(star) {
// clicking the current rating again resets it
this.$emit("input", star === this.value ? 0 : star);
}
}
};
</script>
<style>
.k-rating-field-stars {
display: flex;
}
</style>
import RatingField from "./components/RatingField.vue";
panel.plugin("acme/rating", {
fields: {
rating: RatingField
}
});
inheritAttrs: false keeps all props your component doesn't declare (like saveable, when or width) from ending up as HTML attributes.
The blueprint
Nothing changes for the people using your field:
fields:
rating:
type: rating
label: Rating
max: 5
required: true
empty:
en: "{{ page.title }} has not been rated yet"
de: Noch nicht bewertet
Extending a core field
extends: text is gone. Extend the PHP class of the core field instead. Here's a field for social media handles that builds on the text field, uses another default icon, strips a leading @ and adds a lowercase option.
Kirby 5
<?php
use Kirby\Toolkit\Str;
Kirby::plugin('acme/handle', [
'fields' => [
'handle' => [
'extends' => 'text',
'props' => [
// new default for an inherited prop
'icon' => function (string $icon = 'user') {
return $icon;
},
/**
* Stores the handle in lowercase
*/
'lowercase' => function (bool $lowercase = true) {
return $lowercase;
},
'value' => function ($value = null) {
return ltrim((string)$value, '@');
}
],
'save' => function ($value) {
return $this->lowercase ? Str::lower($value) : $value;
}
]
]
]);
Kirby 6
<?php
namespace Acme\Handle;
use Kirby\Form\Field\TextField;
use Kirby\Reflection\Attributes\Prop;
use Kirby\Toolkit\Str;
class HandleField extends TextField
{
// new default for an inherited prop
protected string|null $icon = 'user';
/**
* Stores the handle in lowercase
*/
#[Prop(export: false)]
protected bool $lowercase = true;
public function fill(mixed $value): static
{
// `@getkirby` → `getkirby`
return parent::fill(
value: ltrim((string)$value, '@')
);
}
public function lowercase(): bool
{
return $this->lowercase;
}
public function toStoredValue(): string
{
$value = parent::toStoredValue();
return $this->lowercase() ? Str::lower($value) : $value;
}
}
<?php
use Acme\Handle\HandleField;
load([
'Acme\Handle\HandleField' => 'classes/HandleField.php'
], __DIR__);
Kirby::plugin('acme/handle', [
'fields' => [
'handle' => HandleField::class
]
]);
lowercase only matters when saving, so export: false keeps it out of the props for the Panel. All options of the text field (counter, pattern, placeholder, …) are inherited.
The field is registered as handle, so the Panel needs a k-handle-field component. If you don't need your own, extend the core component, just like in Kirby 5:
panel.plugin("acme/handle", {
fields: {
handle: {
extends: "k-text-field"
}
}
});
Coming from a Kirby 5 field class
If your field already extended Kirby\Form\FieldClass, the port is mostly mechanical. Here's the core stats field before and after (shortened):
class StatsField extends FieldClass
{
protected array|string $reports;
protected string $size;
public function __construct(array $params)
{
parent::__construct($params);
$this->reports = $params['reports'] ?? [];
$this->size = $params['size'] ?? 'large';
}
public function hasValue(): bool
{
return false;
}
// …
}
class StatsField extends DisplayField
{
#[Prop(computed: true)]
protected array|string $reports = [];
#[Prop]
protected string $size = 'large';
// …
}
- Extend one of the new base classes instead of
FieldClass. - Replace the
$paramsarray and the constructor with typed properties with the#[Prop]attribute. - Replace setter calls (
$this->setLabel(),$this->setRequired(), …) with property defaults. If a value has to be normalized when it's set, name a setter in the attribute, e.g.#[Prop(setter: 'setStep')]. - Model, siblings and value are passed to the constructor as
model,siblingsandvalue. The value is filled after all other options have been stored. - Rename
routes()toapi(). - Replace
$this->params()and the magic__call()fallback for undeclared options with real props and getters.
Fields with subfields
Fields that hold their own set of fields, like structure, object or entries, implement the new Kirby\Form\Interface\ProvidesNestedForm interface. It requires a single form(): Form method. The Kirby\Form\Mixin\Fields trait implements it for you, together with a $fields property and a fields() getter that returns the subfield props for the Panel. Kirby uses the interface to find nested fields by their path, e.g. $form->field('address+city'). API routes and dialogs of subfields (e.g. the picker dialog of a pagepicker inside your field) depend on that lookup.
If your field stores a single object, extending ObjectField is often all you need:
<?php
namespace Acme\Address;
use Kirby\Form\Field\ObjectField;
class AddressField extends ObjectField
{
protected array $fields = [
'street' => ['type' => 'text'],
'zip' => ['type' => 'text', 'width' => '1/3'],
'city' => ['type' => 'text', 'width' => '2/3'],
];
}
For a field written from scratch, implement the interface and use the trait:
use Kirby\Form\Field\InputField;
use Kirby\Form\Interface\ProvidesNestedForm;
use Kirby\Form\Mixin;
class MyNestedField extends InputField implements ProvidesNestedForm
{
use Mixin\Fields;
// …
}
Removed and renamed
All removed and renamed classes, methods and options for fields are listed in the changelog under breaking changes for forms.
Using forms in PHP
The Kirby\Form\Form class runs your field class wherever forms are used: in the Panel, in $page->update() and in your own code. Its constructor and Form::for() only accept named arguments now. The $props array, content(), data(), strings() and values() have been removed.
use Kirby\Exception\FormValidationException;
use Kirby\Form\Form;
// fields from the page blueprint, filled with its content
$form = Form::for($page);
// apply new input, e.g. from a frontend form
$form->submit(input: [
'rating' => 4,
'handle' => '@getkirby'
]);
try {
$form->validate();
} catch (FormValidationException $e) {
// ['rating' => ['label' => 'Rating', 'message' => ['rating' => '…']]]
$errors = $e->getDetails();
}
$form->toFormValues(); // the values as the Panel sees them
$form->toStoredValues(); // the values as they are written to the content file
Forms don't need a blueprint. Pass the field definitions directly and use fill(defaults: true) to fall back to the default of every field that is still empty:
$form = new Form(
fields: [
'rating' => ['type' => 'rating', 'max' => 10, 'default' => 5],
'handle' => ['type' => 'handle']
],
model: $page
);
$values = $form->fill(input: $input, defaults: true)->toStoredValues();
| Removed | Use instead |
|---|---|
$form->content() |
$form->toStoredValues() |
$form->data(), $form->strings() |
$form->toStoredValues(), together with $form->fill(defaults: true) if you need defaults |
$form->values() |
$form->toFormValues() |
new Form(['fields' => …, 'values' => …]) |
new Form(fields: …, model: …) followed by fill()/submit() |
Form::for($page, ['input' => …]) |
Form::for($page)->submit(input: …) |
Form::validate() and Fields::validate() throw the more specific Kirby\Exception\FormValidationException (a subclass of InvalidArgumentException) with all field errors in its details.