Panel backend: controllers & UI classes
The Panel backend has been rebuilt from static helpers into objects. Views, dialogs, drawers, dropdowns, searches and requests can now be handled by controller classes that return UI objects.
What has changed
In Kirby 5, the Panel backend was a collection of static classes, and all core views, dialogs and dropdowns were anonymous closures in kirby/config/areas. This has changed in Kirby 6:
- The Panel is an object, available as
$kirby->panel(), with separate classes for areas, access checks and routing. - Every Panel route can be handled by a controller class instead of a closure. All core routes use them.
- Controllers and closures can return UI objects like
FormDialogorViewinstead ofcomponent/propsarrays. - The term "Fiber" is gone. The data that the backend sends to the frontend is called Panel state now.
The Panel object
Kirby\Panel\Panel and Kirby\Panel\Home are no longer static. Get the Panel object from the app:
$panel = $kirby->panel();
| Kirby 5 | Kirby 6 |
|---|---|
Panel::url($path) |
$kirby->panel()->url($path) |
Panel::areas() (array) |
$kirby->panel()->areas() (Kirby\Panel\Areas collection) |
Panel::area($id, $definition) |
Kirby\Panel\Areas::area($id, $definition) (returns a Kirby\Panel\Area) |
Panel::buttons() |
$kirby->panel()->areas()->buttons() |
Panel::firewall($user, $area) |
$kirby->panel()->access()->area($area, throws: true) |
Panel::hasAccess($user, $area) |
$kirby->panel()->access()->area($area) |
Panel::isFiberRequest() |
$kirby->panel()->isStateRequest() |
Panel::multilang(), Panel::referrer() |
$kirby->panel()->multilang(), $kirby->panel()->referrer() |
Panel::router($path) |
$kirby->panel()->router()?->call($path) |
Panel::routesForViews() etc. |
Kirby\Panel\Routes\ViewRoutes etc. |
Home::url() |
$kirby->panel()->home()->url() |
Home::isPanelUrl($url) |
$kirby->panel()->isPanelUrl($url) |
Home::panelPath($url) |
$kirby->panel()->path($url) |
Defining an area
Areas are registered as before. Each definition is turned into a Kirby\Panel\Area object. The area accepts these options: breadcrumb, breadcrumbLabel, buttons, dialogs, drawers, dropdowns, icon, label, link, menu, requests, search, searches, title and views.
Route definitions
Each type of route in an area accepts these keys:
| Type | Definition | Handler in Kirby 5 | Handler in Kirby 6 |
|---|---|---|---|
views |
pattern, when |
action |
action: closure or controller class |
dialogs, drawers |
pattern (defaults to the name) |
load, submit |
load/submit closures or action: controller class |
dropdowns |
pattern (defaults to the name) or the name as pattern with a closure as value |
options or action |
options or action: closure or controller class |
searches |
label, icon |
query |
query closure or action: closure or controller class |
requests |
pattern, method, auth |
action |
action: closure or controller class |
Closures keep working
An area like this one from Kirby 5 runs in Kirby 6 without changes:
'dialogs' => [
'todo.update' => [
'pattern' => 'todos/(:any)/update',
'load' => fn (string $id) => [
'component' => 'k-form-dialog',
'props' => [
'fields' => [
'text' => ['label' => 'Todo', 'type' => 'text']
],
'value' => todo($id)
]
],
'submit' => function (string $id) {
// save the todo
return true;
}
]
]
There are only two things to watch out for: plain strings in props like a dialog's text are escaped now (see Trusted HTML), and errors are handled differently (see Better errors in the Panel).
Controller classes
A controller is a class that handles one Panel route. Instead of a closure, you pass its class name as action:
'dialogs' => [
'todo.update' => [
'pattern' => 'todos/(:any)/update',
'action' => TodoUpdateDialogController::class
]
]
The class must extend the base controller for the route type:
| Route type | Base class |
|---|---|
views |
Kirby\Panel\Controller\ViewController |
dialogs |
Kirby\Panel\Controller\DialogController |
drawers |
Kirby\Panel\Controller\DrawerController |
dropdowns |
Kirby\Panel\Controller\DropdownController |
searches |
Kirby\Panel\Controller\SearchController |
requests |
Kirby\Panel\Controller\RequestController |
How a controller is created
When the route is called, Kirby creates the controller with the arguments from the route pattern. If the class has a static factory() method, Kirby calls it, otherwise the constructor:
class TodoUpdateDialogController extends DialogController
{
public function __construct(
public string $id,
public array $todo
) {
parent::__construct();
}
// called with the (:any) part of `todos/(:any)/update`
public static function factory(string $id): static
{
return new static(id: $id, todo: todo($id));
}
}
The factory is the place to turn the URL arguments into the objects your controller works with, like the core controllers do with Find::page($id). The constructor then only takes ready-made objects, which makes the controller easy to test.
You can also create the controller yourself in a closure, e.g. to pass extra arguments:
'action' => fn (string $id) => new TodoUpdateDialogController(
id: $id,
todo: todo($id)
)
load() and submit()
Every controller implements ::load(), which returns what the route sends to the Panel. Dialog and drawer controllers also handle the form submission in ::submit(). The base class returns true there, which closes the dialog successfully.
Search controllers
Search controllers get the search query, limit and page from the request in their ::factory() method. Implement ::load() and use the properties:
use Kirby\Panel\Controller\SearchController;
class TodosSearchController extends SearchController
{
public function load(): array
{
$results = [];
foreach (todos() as $id => $todo) {
if (str_contains($todo['text'], $this->query) === true) {
$results[] = [
'text' => $todo['text'],
'link' => '/todos',
'icon' => 'check'
];
}
}
return $results;
}
}
UI objects
Controllers and closures can still return component/props arrays. But they can also return UI objects from the Kirby\Panel\Ui namespace. Kirby calls their render() method and sends the result to the Panel.
// Kirby 5
return [
'component' => 'k-todos-view',
'title' => 'Todos',
'props' => [
'todos' => todos()
]
];
// Kirby 6
return new View(
component: 'k-todos-view',
title: 'Todos',
todos: todos()
);
A RemoveDialog comes with a trash icon and the negative theme for its submit button. Text, remove and form dialogs default to the medium size. The text of these dialogs is escaped unless it is trusted HTML. Use $this->i18nHtml() to insert user content into HTML safely:
return new RemoveDialog(
text: $this->i18nHtml(
'Do you really want to delete <strong>{text}</strong>?',
['text' => $this->todo['text']]
)
);
Submit results
::submit() returns the same results as the submit closures in Kirby 5:
truecloses the dialog or drawer.- An array can trigger an event (
'event' => 'todo.update') and/or redirect to another Panel URL ('redirect' => 'todos'). - An exception shows an error. To redirect from anywhere, call
Panel::go('todos').
Complete example: a Panel area in Kirby 5 and 6
Let's build a small todo list: a view that lists all todos, a dropdown with options for each todo and two dialogs to edit and delete a todo. The todos are stored in a JSON file in the plugin folder for the sake of our example. These helper functions are the same in both versions:
use Kirby\Data\Data;
use Kirby\Exception\NotFoundException;
function todos(): array
{
return Data::read(__DIR__ . '/todos.json');
}
function todo(string $id): array
{
return todos()[$id] ?? throw new NotFoundException(
message: 'The todo "' . $id . '" does not exist'
);
}
function saveTodos(array $todos): void
{
Data::write(__DIR__ . '/todos.json', $todos);
}
{
"milk": { "text": "Buy milk", "done": false },
"bike": { "text": "Fix bike", "done": true }
}
Kirby 5
In Kirby 5, everything lives in closures inside the area definition:
Kirby::plugin('acme/todos', [
'areas' => [
'todos' => fn () => [
'label' => 'Todos',
'icon' => 'check',
'menu' => true,
'views' => [
'todos' => [
'pattern' => 'todos',
'action' => fn () => [
'component' => 'k-todos-view',
'title' => 'Todos',
'props' => [
'todos' => todos()
]
]
]
],
'dialogs' => [
'todo.update' => [
'pattern' => 'todos/(:any)/update',
'load' => fn (string $id) => [
'component' => 'k-form-dialog',
'props' => [
'fields' => [
'text' => [
'label' => 'Todo',
'type' => 'text',
'required' => true
],
'done' => [
'label' => 'Done',
'type' => 'toggle'
]
],
'value' => todo($id)
]
],
'submit' => function (string $id) {
todo($id);
$todos = todos();
$todos[$id] = [
'text' => get('text'),
'done' => get('done', false)
];
saveTodos($todos);
return [
'event' => 'todo.update'
];
}
],
'todo.delete' => [
'pattern' => 'todos/(:any)/delete',
'load' => fn (string $id) => [
'component' => 'k-remove-dialog',
'props' => [
'text' => 'Do you really want to delete "' . Escape::html(todo($id)['text']) . '"?'
]
],
'submit' => function (string $id) {
$todos = todos();
unset($todos[$id]);
saveTodos($todos);
return true;
}
]
],
'dropdowns' => [
'todo' => [
'pattern' => 'todos/(:any)',
'options' => fn (string $id) => [
[
'text' => 'Edit',
'icon' => 'edit',
'dialog' => 'todos/' . $id . '/update'
],
[
'text' => 'Delete',
'icon' => 'trash',
'dialog' => 'todos/' . $id . '/delete'
]
]
]
]
]
]
]);
Kirby 6
In Kirby 6, the area definition only maps routes to controllers:
<?php
use Acme\Todos\TodoDeleteDialogController;
use Acme\Todos\TodoDropdownController;
use Acme\Todos\TodosViewController;
use Acme\Todos\TodoUpdateDialogController;
load([
'Acme\Todos\TodoDeleteDialogController' => 'src/TodoDeleteDialogController.php',
'Acme\Todos\TodoDropdownController' => 'src/TodoDropdownController.php',
'Acme\Todos\TodosViewController' => 'src/TodosViewController.php',
'Acme\Todos\TodoUpdateDialogController' => 'src/TodoUpdateDialogController.php',
], __DIR__);
// the todos(), todo() and saveTodos() helpers from above
Kirby::plugin('acme/todos', [
'areas' => [
'todos' => fn () => [
'label' => 'Todos',
'icon' => 'check',
'menu' => true,
'views' => [
'todos' => [
'pattern' => 'todos',
'action' => TodosViewController::class
]
],
'dialogs' => [
'todo.update' => [
'pattern' => 'todos/(:any)/update',
'action' => TodoUpdateDialogController::class
],
'todo.delete' => [
'pattern' => 'todos/(:any)/delete',
'action' => TodoDeleteDialogController::class
]
],
'dropdowns' => [
'todo' => [
'pattern' => 'todos/(:any)',
'action' => TodoDropdownController::class
]
]
]
]
]);
The view controller returns a View object:
<?php
namespace Acme\Todos;
use Kirby\Panel\Controller\ViewController;
use Kirby\Panel\Ui\View;
class TodosViewController extends ViewController
{
public function load(): View
{
return new View(
component: 'k-todos-view',
title: 'Todos',
todos: todos()
);
}
}
The update dialog looks up the todo in its factory, returns a FormDialog and saves the submitted values:
<?php
namespace Acme\Todos;
use Kirby\Panel\Controller\DialogController;
use Kirby\Panel\Ui\Dialog\FormDialog;
class TodoUpdateDialogController extends DialogController
{
public function __construct(
public string $id,
public array $todo
) {
parent::__construct();
}
public static function factory(string $id): static
{
return new static(id: $id, todo: todo($id));
}
public function load(): FormDialog
{
return new FormDialog(
fields: [
'text' => [
'label' => 'Todo',
'type' => 'text',
'required' => true
],
'done' => [
'label' => 'Done',
'type' => 'toggle'
]
],
value: $this->todo
);
}
public function submit(): array
{
$todos = todos();
$todos[$this->id] = [
'text' => $this->request->get('text'),
'done' => $this->request->get('done', false)
];
saveTodos($todos);
return [
'event' => 'todo.update'
];
}
}
The delete dialog returns a RemoveDialog with trusted HTML, in which the todo text is escaped:
<?php
namespace Acme\Todos;
use Kirby\Panel\Controller\DialogController;
use Kirby\Panel\Ui\Dialog\RemoveDialog;
class TodoDeleteDialogController extends DialogController
{
public function __construct(
public string $id,
public array $todo
) {
parent::__construct();
}
public static function factory(string $id): static
{
return new static(id: $id, todo: todo($id));
}
public function load(): RemoveDialog
{
return new RemoveDialog(
text: $this->i18nHtml(
'Do you really want to delete <strong>{text}</strong>?',
['text' => $this->todo['text']]
)
);
}
public function submit(): bool
{
$todos = todos();
unset($todos[$this->id]);
saveTodos($todos);
return true;
}
}
The dropdown controller returns the options as an array:
<?php
namespace Acme\Todos;
use Kirby\Panel\Controller\DropdownController;
class TodoDropdownController extends DropdownController
{
public function __construct(
public string $id
) {
parent::__construct();
}
public function load(): array
{
return [
[
'text' => 'Edit',
'icon' => 'edit',
'dialog' => 'todos/' . $this->id . '/update'
],
[
'text' => 'Delete',
'icon' => 'trash',
'dialog' => 'todos/' . $this->id . '/delete'
]
];
}
}
The view component
The Vue component for the view is the same in both versions. It passes the dropdown URL as options to each item, so the Panel loads the options from the dropdown route when the dropdown opens:
panel.plugin("acme/todos", {
components: {
"k-todos-view": {
props: {
todos: Object
},
computed: {
items() {
return Object.entries(this.todos).map(([id, todo]) => ({
id: id,
text: todo.text,
info: todo.done ? "Done" : "",
options: "todos/" + id
}));
}
},
template: `
<k-panel-inside>
<k-header>Todos</k-header>
<k-items :items="items" />
</k-panel-inside>
`
}
}
});
If you compile your components with a build tool, you need a Vue 3 build for Kirby 6. See Vue 3 for Panel plugins.
What you gain
The controller version needs more files, but each route is now a small class with a clear job:
- Testable: You can create a controller with test data, call
load()andsubmit()and check the result without any HTTP request. - Reusable: Other plugins (and you) can extend your controllers or call them, like you can with the core controllers.
- Safe by default:
i18nHtml()and the UI classes make it easy to send trusted HTML without forgetting to escape user content.
Extending core areas
Plugins can still extend the core areas. Kirby merges your definition into the core definition, so you only define what you want to change.
Removing core definitions
Set any core view, dialog, drawer, dropdown, search, request or view button to false to remove it:
Kirby::plugin('acme/no-file-search', [
'areas' => [
'site' => [
'searches' => [
'files' => false
]
]
]
]);
Overriding core routes
All core routes are defined with a controller class in action now. Override them like this:
- Views: Set
actionto your closure or controller class. - Dialogs and drawers: Set
loadand/orsubmitclosures. They take precedence over the core controller, so you can e.g. replace onlyloadand keep the coresubmit. Or replaceaction. - Dropdowns: Set
optionsoraction. - Searches: Set
action. The Kirby 5queryoption is ignored when the core definition already has anaction, so an override withquerysilently does nothing.
// Kirby 5
'searches' => [
'pages' => [
'query' => fn (string $query, int $limit, int $page) => mySearch($query, $limit, $page)
]
]
// Kirby 6
'searches' => [
'pages' => [
'action' => fn (string $query, int $limit, int $page) => mySearch($query, $limit, $page)
]
]
Reusing core code
In Kirby 5, you could fall back to the core closures, e.g. $kirby->core()->area('site')['dialogs']['page.changeStatus']['load']($id). In Kirby 6, the core definitions only contain class names. Use the controllers instead:
use Kirby\Cms\Find;
use Kirby\Panel\Controller\Dialog\PageChangeStatusDialogController;
use Kirby\Panel\Controller\Dropdown\PageSettingsDropdownController;
use Kirby\Panel\Controller\Search\PagesSearchController;
Kirby::plugin('acme/custom-status', [
'areas' => [
'site' => fn () => [
'dialogs' => [
'page.changeStatus' => [
'load' => function (string $id) {
if ($id === 'blog') {
// custom dialog for the blog
}
return PageChangeStatusDialogController::factory($id)->load();
}
]
],
'dropdowns' => [
'page' => [
'action' => function (string $id) {
$options = PageSettingsDropdownController::factory($id)->load();
$options[] = '-';
$options[] = [
'text' => 'Custom option',
'icon' => 'star',
'click' => 'custom'
];
return $options;
}
]
],
'searches' => [
'pages' => [
'action' => function (string $query, int $limit, int $page) {
// e.g. your own search for some queries …
return (new PagesSearchController($query, $limit, $page))->load();
}
]
]
]
]
]);
You find all core controllers in kirby/config/areas and kirby/src/Panel/Controller.
Custom model views
To render a page, site, file or user view with your own component, extend the core view controller instead of modifying the view array:
// Kirby 5
'views' => [
'page' => [
'action' => function (string $path) {
$view = Find::page($path)->panel()->view();
$view['component'] = 'k-my-page-view';
return $view;
}
]
]
// Kirby 6
use Kirby\Panel\Controller\View\PageViewController;
class MyPageViewController extends PageViewController
{
public function component(): string
{
return 'k-my-page-view';
}
}
// in the area definition:
'views' => [
'page' => [
'action' => MyPageViewController::class
]
]
Panel model classes
Kirby\Panel\Model and its subclasses ($page->panel() etc.) still exist, but the view-related methods are deprecated in favor of the view controllers:
| Deprecated | Use instead |
|---|---|
$model->panel()->breadcrumb() |
ModelViewController::breadcrumb() |
$model->panel()->buttons() |
ModelViewController::buttons() |
$model->panel()->prevNext() |
ModelViewController::prev() and ::next() |
$model->panel()->props() |
ModelViewController::props() |
$model->panel()->versions() |
ModelViewController::versions() |
$model->panel()->view() |
ModelViewController::load() |
$page->panel()->dropdown(), $file->panel()->dropdown(), $user->panel()->dropdown() |
PageSettingsDropdownController, FileSettingsDropdownController, UserSettingsDropdownController |
ModelViewController is the base class of SiteViewController, PageViewController, FileViewController, UserViewController and their variants in Kirby\Panel\Controller\View, e.g. PageViewController::factory('blog') or new PageViewController($page).
More changes
Most classes of the old static Panel have been moved or replaced. The term "Fiber" is gone: the data the backend sends to the Panel is called Panel state now. New items/pages, items/files and items/users request endpoints turn model IDs into item data, and exceptions in Panel routes go through Kirby's regular error handler.
You'll find all details in the changelog: the breaking changes for the Panel backend and the Panel frontend, and the new backend features for plugin developers.