Plugin development
Plugins live in app/Plugin/<Name>/. The directory name is the plugin's identifier; use PascalCase.
Directory layout
app/Plugin/Demo/
Config/
Info.php Plugin metadata (required)
Config.php Default configuration values
Submit.js Admin settings form (legacy Submit.php also works)
Hook/
Main.php Hook subscriptions
Lifecycle.php Lifecycle callbacks (optional; can live in Main.php)
Controller/
Api.php The plugin's own routes
View/
index.html TemplatesOnly Config/Info.php is required; add the rest as needed.
Config/Info.php
<?php
declare(strict_types=1);
use App\Consts\Plugin;
return [
Plugin::NAME => 'Demo plugin',
Plugin::AUTHOR => 'Your name',
Plugin::WEB_SITE => 'https://example.com',
Plugin::DESCRIPTION => 'One line describing what this does',
Plugin::VERSION => '1.0.0'
];Config/Config.php
Default configuration values. STATUS is the conventional enabled flag:
<?php
declare(strict_types=1);
return [
'STATUS' => '0',
'api_key' => '',
'enable_notify' => '1',
];Config/Submit.js — the settings form
This file defines what the settings dialog in the admin panel looks like.
Two formats exist, and Submit.js wins. When both files are present, Submit.js overrides Submit.php.
| Type | New format (recommended) | Legacy format |
|---|---|---|
| Plugins | Config/Submit.js | Config/Submit.php |
| Payment plugins | Config/Submit.js | Config/Submit.php |
| Themes | Submit.js (theme root, not under Config/) | the SUBMIT constant on the Config interface |
The path for themes differs from plugins — do not put it in
Config/. Getting it wrong produces no error; the form simply never appears.
Writing one
The file is eval'd by the front end, so the whole file must be a single expression. A bare array is the simplest form:
[
{
name: `${util.icon("fa-duotone fa-regular fa-gear")} General`,
form: [
{
title: "Merchant ID",
name: "mch_id",
type: "input",
placeholder: "The merchant ID issued by the provider",
required: true
},
{
title: "Enable notifications",
name: "enable_notify",
type: "switch",
text: "Enable"
}
]
},
{
name: "Advanced",
form: [
{ title: "Timeout", name: "timeout", type: "number", placeholder: "seconds" }
]
}
]Wrap it in an IIFE when you need helper functions:
(() => {
const T = (s) => (typeof i18n === "function" ? i18n(s) : s);
const cfg = (k, d = "") => (assign && assign[k] != null && assign[k] !== "" ? assign[k] : d);
return [
{
name: T("General"),
form: [
{ title: T("API key"), name: "api_key", type: "input", default: cfg("api_key") }
]
}
];
})()Structure: groups and fields
Unlike the flat field array of Submit.php, Submit.js is grouped. Each group becomes a tab in the dialog: name is the tab title (it accepts util.icon()), and form holds the fields.
Field types
| type | Control |
|---|---|
input | Single-line text field |
password | Password field |
number | Number field |
textarea | Multi-line text area |
select | Dropdown |
radio | Radio buttons |
checkbox | Checkboxes |
switch | Toggle |
image | Image upload |
explain | Static help text, never saved |
html | Raw HTML inserted as-is |
custom | Render it yourself, see below |
What is available in scope
| Variable | Purpose |
|---|---|
assign | The currently saved configuration — read values with assign.api_key |
util | Helper functions; util.icon() is the one you will use most |
i18n(s) | Translation function, for multi-language plugins |
layui | The layui instance |
Submit.jsdoes not fill in defaults for you. WithSubmit.php, the core injects values fromConfig.phpinto each field'sdefault. With.jsthe core merely reads the file as a string and hands it to the front end, so that step never runs. Read current values fromassignyourself — which is why every plugin defines a smallcfg()helper.
custom: render it yourself
For anything the field types cannot express:
{
title: false,
name: "gateway",
type: "custom",
complete: (form, dom) => {
dom.html(`<a href="/plugin/Demo/panel" class="btn btn-sm btn-primary">Open panel</a>`);
}
}
complete()is called repeatedly — switching tabs or rebuilding the form both trigger it. If you start a timer or a polling loop inside, make sure the previous round invalidates itself, or it will keep running in the background after the dialog is closed.
Can I still use Submit.php?
Yes. The core accepts both, and plenty of plugins in the repository still use .php.
<?php
declare(strict_types=1);
return [
["title" => "API key", "name" => "api_key", "type" => "input", "placeholder" => "Enter your API key"],
["title" => "Note", "name" => "explain", "type" => "explain", "placeholder" => "This text is only a hint and is never saved"],
["title" => "Enable notifications", "name" => "enable_notify", "type" => "switch", "text" => "Enable"],
];Submit.php is a flat array of fields with no grouping, and it additionally supports file, editor and json, which .js does not. It is perfectly adequate for a simple form; reach for .js when you need tabs, conditional visibility or custom rendering.
Do not pass JSON strings through configuration. The
$_POSTsuperglobal is sanitised globally; submit form arrays instead.
Subscribing to hooks
<?php
declare(strict_types=1);
namespace App\Plugin\Demo\Hook;
use App\Controller\Base\View\UserPlugin;
use Kernel\Annotation\Hook;
class Main extends UserPlugin
{
#[Hook(point: \App\Consts\Hook::USER_VIEW_FOOTER)]
public function footer(): void
{
echo '<script>console.log("hello from Demo")</script>';
}
}Pick the base class by context:
| Base class | Used for |
|---|---|
App\Controller\Base\View\UserPlugin | Storefront |
App\Controller\Base\View\ManagePlugin | Admin panel |
All available points are listed in the hook reference.
Subscribers should use hexadecimal literals (
#[Hook(point: 0x2300)]) rather than referencing constants. Attribute arguments are evaluated when the plugin is enabled; on an older core that lacks the constant this throws an Error and leaves the plugin half-enabled — START executed, STATUS never written.
Lifecycle
<?php
declare(strict_types=1);
namespace App\Plugin\Demo\Hook;
use Kernel\Annotation\Plugin;
class Lifecycle
{
#[Plugin(state: Plugin::INSTALL)]
public function install(): void
{
// Install: create tables
}
#[Plugin(state: Plugin::START)]
public function start(): void
{
// Every time it is enabled
}
#[Plugin(state: Plugin::STOP)]
public function stop(): void
{
// Disabled
}
#[Plugin(state: Plugin::UNINSTALL)]
public function uninstall(): void
{
// Uninstall: clean up data
}
#[Plugin(state: Plugin::UPGRADE)]
public function upgrade(): void
{
// Upgrade
}
#[Plugin(state: Plugin::SAVE_CONFIG)]
public function saveConfig(): void
{
// After settings are saved in the admin panel
}
}The
state:named argument is mandatory. Written positionally as#[Plugin(Plugin::INSTALL)]it silently never fires — the plugin reports "enabled successfully" while your table creation never ran, and every later feature fails with "table does not exist". The core reads$arguments['state'].
Plugin pages
Methods in Controller/Api.php map to routes:
/plugin/<PluginName>/<controller>/<method>So a login() method in app/Plugin/Demo/Controller/Api.php is reachable at:
/plugin/Demo/api/loginRendering a template:
echo $this->render("Title", "index.html", ['key' => 'value']);Injecting UI into the storefront
HTML and CSS injected into the storefront will fight with the active theme's styles. Two rules of thumb:
- Always write CSS as
#your-root-id .your-classto raise specificity; never use bare class names - Wrap resets in
:where()so they do not affect the theme's own elements
Also worth knowing: scrollIntoView will scroll any ancestor with overflow:hidden, so be careful with it inside storefront modals.
Packaging and publishing
When you submit a plugin through the developer centre, the server packages the entire directory automatically, excluding only Config/Config.php.
So move large files out first — binaries, runtime.log, caches. Otherwise the package will be enormous.
Debugging
- Errors go to
runtime.login the site root - After changing hook files, disable and re-enable the plugin to rebuild the hook registry
- After changing templates, delete the contents of
runtime/view/compile
