Skip to content

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    Templates

Only Config/Info.php is required; add the rest as needed.

Config/Info.php

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
<?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.

TypeNew format (recommended)Legacy format
PluginsConfig/Submit.jsConfig/Submit.php
Payment pluginsConfig/Submit.jsConfig/Submit.php
ThemesSubmit.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:

js
[
    {
        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:

js
(() => {
    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

typeControl
inputSingle-line text field
passwordPassword field
numberNumber field
textareaMulti-line text area
selectDropdown
radioRadio buttons
checkboxCheckboxes
switchToggle
imageImage upload
explainStatic help text, never saved
htmlRaw HTML inserted as-is
customRender it yourself, see below

What is available in scope

VariablePurpose
assignThe currently saved configuration — read values with assign.api_key
utilHelper functions; util.icon() is the one you will use most
i18n(s)Translation function, for multi-language plugins
layuiThe layui instance

Submit.js does not fill in defaults for you. With Submit.php, the core injects values from Config.php into each field's default. With .js the core merely reads the file as a string and hands it to the front end, so that step never runs. Read current values from assign yourself — which is why every plugin defines a small cfg() helper.

custom: render it yourself

For anything the field types cannot express:

js
{
    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
<?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 $_POST superglobal is sanitised globally; submit form arrays instead.

Subscribing to hooks

php
<?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 classUsed for
App\Controller\Base\View\UserPluginStorefront
App\Controller\Base\View\ManagePluginAdmin 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
<?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/login

Rendering a template:

php
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:

  1. Always write CSS as #your-root-id .your-class to raise specificity; never use bare class names
  2. 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.log in 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

Released under the MIT License