Development

Plugin Development

Learn how to build your own plugins for Kleeja.

Kleeja plugins add features to the script without touching core files. A plugin lives in its own folder under plugins/, declares itself in a single init.php file, and attaches callbacks to hooks that core code fires while a page runs.

Because plugins never patch core, upgrading Kleeja does not wipe your changes. The system loads only installed and enabled plugins, so a broken plugin can be disabled from the admin panel instead of edited out by hand.

This page documents the plugin system in Kleeja 4. The loader lives in includes/plugins.php and the admin manager in includes/adm/j_plugins.php. Kleeja 4 requires PHP 8.2, and its database layer changed, so a plugin written for Kleeja 3 needs a few changes. See Upgrading a plugin from Kleeja 3.

How it works

The Plugins singleton starts with the first hook that core fires, early in includes/common.php while the caches load. Its constructor reads the plugins table, keeps the rows where plg_disabled = 0, and includes the init.php of each matching folder in plugins/.

Each init.php fills a $kleeja_plugin array with the plugin's metadata, lifecycle callbacks and hook functions. The loader registers those hook functions in a global map, ordered by the plugin's priority.

Core files then call runHook('hook_name', get_defined_vars()) at fixed points. Every function registered for that hook receives the caller's variables, and whatever it returns is merged back into the caller's scope.

Folder structure

A plugin is a folder whose name matches the key used inside init.php. Kleeja reads the folder name from disk and looks it up in the array, so the two must be identical.

plugins/
plugins/
└── my_plugin/
    ├── init.php      # required — the whole plugin definition
    ├── icon.png      # optional — shown in the plugin list and on the Help page
    ├── index.html    # optional — blocks directory listing
    ├── language/     # optional — help words, one file per language
    └── templates/    # optional — your own .html templates
The folder name is the plugin key. plugins/my_plugin/init.php must define $kleeja_plugin['my_plugin'], or the loader skips the plugin. Use lowercase letters, digits, dots and underscores, with at least three characters.

The init.php file

Every init.php starts with a guard. The IN_PLUGINS_SYSTEM constant is defined by includes/plugins.php, so the file exits when someone requests it directly through the browser.

plugins/my_plugin/init.php
<?php
// prevent illegal run
if (! defined('IN_PLUGINS_SYSTEM')) {
    exit();
}

$kleeja_plugin['my_plugin']['information'] = [ /* ... */ ];
$kleeja_plugin['my_plugin']['first_run']['en'] = 'Thanks for installing!';
$kleeja_plugin['my_plugin']['install']   = function ($plg_id) { /* ... */ };
$kleeja_plugin['my_plugin']['update']    = function ($old_version, $new_version) { /* ... */ };
$kleeja_plugin['my_plugin']['uninstall'] = function ($plg_id) { /* ... */ };
$kleeja_plugin['my_plugin']['functions'] = [ /* hook_name => callable */ ];
Define information, install, update, uninstall and functions even when a callback is empty. Only first_run is optional. The loader and the admin manager read the other keys directly, and a missing key raises a PHP warning. A plugin without an uninstall callback cannot be uninstalled from the admin panel at all, because the manager redirects back to the plugin list.

Plugin information

The information array describes the plugin to the admin panel and drives compatibility checks and load order.

KeyTypePurpose
plugin_titlearrayDisplay name per language code, for example ['en' => 'My Plugin', 'ar' => 'إضافتي']. Falls back to en, then to the first entry.
plugin_developerstringAuthor name, stored in plg_author.
plugin_versionstringPlugin version. Drives the update callback and the store's update check, so bump it on every release.
plugin_descriptionarrayShort description per language code. Shown in the plugin list, truncated to 100 characters.
plugin_kleeja_version_minstringLowest supported Kleeja version. Installation fails below it. Leave it empty or 0 for no limit.
plugin_kleeja_version_maxstringHighest supported Kleeja version. Installation fails above it. Leave it empty or 0 for no limit.
plugin_priorityintLoad order. Higher numbers run first; 0 is normal.
settings_pagestringOptional. Query string of the plugin's settings page, appended to admin/?. Adds a settings button next to the plugin and a link on the Help page.
plugins/my_plugin/init.php
$kleeja_plugin['my_plugin']['information'] = [
    'plugin_title' => [
        'en' => 'My Plugin',
        'ar' => 'إضافتي',
    ],
    'plugin_developer' => 'Your Name',
    'plugin_version'   => '1.0',
    'plugin_description' => [
        'en' => 'Does something useful',
        'ar' => 'تقوم بشيء مفيد',
    ],
    'plugin_kleeja_version_min' => '4.0.0',
    'plugin_kleeja_version_max' => '4.9',
    'plugin_priority'           => 0,
    'settings_page'             => 'cp=options&smt=my_plugin',
];
Version limits are checked once, at install time, with version_compare() against KLEEJA_VERSION. Set plugin_kleeja_version_max generously, because a tight maximum blocks installation on newer Kleeja releases even when the plugin still works. A Kleeja 3 plugin with a maximum of 3.9 cannot be installed on Kleeja 4.0.0, and a maximum of 4.9 stops at 4.9.1.

Write settings_page with a plain &, as above. The Help page encodes the link itself, so &amp; there turns into a broken link. A full http:// or https:// URL is ignored.

First run message

The first_run key holds an HTML message shown right after a successful install. Kleeja picks the entry matching the site language and falls back to en.

plugins/my_plugin/init.php
$kleeja_plugin['my_plugin']['first_run']['en'] = '
Thank you for installing this plugin. Report bugs to: <br>
info@example.com
';

When first_run is missing, the admin panel redirects back to the plugin list after two seconds instead of waiting for a click.

Install callback

The install callback runs once, after the plugin row is inserted into the plugins table. It receives the new plg_id, which you pass to add_config_r() and add_olang() so Kleeja can tie your settings and language strings to the plugin.

plugins/my_plugin/init.php
$kleeja_plugin['my_plugin']['install'] = function ($plg_id) {
    add_config_r([
        'my_plugin_enabled' => [
            'value'  => '1',
            'html'   => configField('my_plugin_enabled', 'yesno'),
            'plg_id' => $plg_id,
            'type'   => 'my_plugin',
            'order'  => '1',
        ],
        'my_plugin_api_key' => [
            'value'  => '',
            'html'   => configField('my_plugin_api_key'),
            'plg_id' => $plg_id,
            'type'   => 'my_plugin',
            'order'  => '2',
        ],
    ]);

    add_olang([
        'CONFIG_KLJ_MENUS_MY_PLUGIN' => 'My Plugin settings',
        'MY_PLUGIN_ENABLED'          => 'Enable the plugin',
        'MY_PLUGIN_API_KEY'          => 'API key',
    ], 'en', $plg_id);
};
Kleeja suppresses SQL errors during installation, so a failing query fails silently, and the plugin row already exists by then. Check the return value of $SQL->query() for the queries that matter, and test the install callback on both MySQL and SQLite before you publish.

Update callback

The loader compares the installed version stored in plg_ver with plugin_version from init.php on every request. When the file declares a newer version, Kleeja calls update, then writes the new version to the database.

The callback runs while the plugins are still loading, before Kleeja has loaded the current user and the groups. Use $SQL->build() directly there:

  • add_config(), update_config(), delete_config() and get_config() read the groups of the current user, and they stop the request with a fatal error.
  • delete_cache(), kleeja_unlink() and the other helpers that fire a hook start a second plugin loader. Delete cache files with unlink() instead.
plugins/my_plugin/init.php
$kleeja_plugin['my_plugin']['update'] = function ($old_version, $new_version) {
    global $SQL, $dbprefix;

    $result = $SQL->build([
        'SELECT' => 'plg_id',
        'FROM'   => "{$dbprefix}plugins",
        'WHERE'  => 'plg_name = :name',
        'BIND'   => ['name' => 'my_plugin'],
    ]);
    $plg_id = (int) ($SQL->fetch($result)['plg_id'] ?? 0);
    $SQL->freeresult($result);

    $result = $SQL->build([
        'SELECT' => 'name',
        'FROM'   => "{$dbprefix}config",
        'WHERE'  => 'name = :name',
        'BIND'   => ['name' => 'my_plugin_timeout'],
    ]);
    $has_timeout = $SQL->fetch($result) !== false;
    $SQL->freeresult($result);

    if (version_compare($old_version, '1.1', '<') && ! $has_timeout) {
        $SQL->build([
            'INSERT' => '`name`, `value`, `option`, `display_order`, `type`, `plg_id`',
            'INTO'   => "{$dbprefix}config",
            'VALUES' => ':name, :value, :option, :order, :type, :plg_id',
            'BIND'   => [
                'name'   => 'my_plugin_timeout',
                'value'  => '30',
                'option' => configField('my_plugin_timeout'),
                'order'  => 3,
                'type'   => 'my_plugin',
                'plg_id' => $plg_id,
            ],
        ]);
    }

    if (version_compare($old_version, '2.0', '<')) {
        $SQL->build([
            'UPDATE' => "{$dbprefix}config",
            'SET'    => 'value = :value',
            'WHERE'  => 'name = :name',
            'BIND'   => ['value' => '', 'name' => 'my_plugin_api_key'],
        ]);
    }

    // the settings are cached, and delete_cache() can't run here
    $cache_file = PATH . 'cache/data_config.php';

    if (file_exists($cache_file)) {
        if (function_exists('opcache_invalidate')) {
            @opcache_invalidate($cache_file, true);
        }

        @unlink($cache_file);
    }
};

Guard each migration with version_compare() on $old_version. Users may upgrade across several releases at once, so the callback must handle every path from any older version to the current one.

Make every step safe to run twice. SQL errors are not hidden during an update, so a failing query shows an error page, and Kleeja saves the new version only after the whole callback has run. A step that fails, such as inserting a setting that exists already, therefore stops every page of the site until the plugin is fixed.

$kleeja_plugin is a local variable of the loader, not a global. A callback that needs the plugin's own information takes it with function (...) use ($kleeja_plugin). An update does not clear the compiled templates either, so a plugin that changes markup through style_parse_func deletes its cache/tpl_*.php files in the update callback.

Uninstall callback

The uninstall callback runs before the plugin row is deleted. Remove everything the install callback created: settings, language strings and any custom tables.

plugins/my_plugin/init.php
$kleeja_plugin['my_plugin']['uninstall'] = function ($plg_id) {
    delete_config([
        'my_plugin_enabled',
        'my_plugin_api_key',
    ]);

    // a plg_id of 0 would delete the words of every plugin
    if ($plg_id > 0) {
        delete_olang(null, null, $plg_id);
    }
};
Passing null as the first argument to delete_olang() deletes every string of the given language, and a null language means all of them. With a $plg_id, only the plugin's strings go. The admin manager passes 0 when the plugin has no row, and delete_olang(null, 'en', 0) deletes the English strings of every installed plugin, so always check $plg_id > 0 first.

Hooks

Hooks are the plugin system's only entry point into core logic. Kleeja ships more than 200 of them, scattered through index.php, go.php, do.php, ucp.php, admin/index.php and the files in includes/.

How core fires a hook

Every core call site looks like this. The hook receives every variable in scope at that point, and extract() writes the returned values back over them.

do.php
extract(runHook('begin_download_page', get_defined_vars()));

runHook() is a shorthand for Plugins::getInstance()->run(). Kleeja 3 core also used a longer is_array($plugin_run_result = ...) form, which the Kleeja 4 core no longer uses.

Writing a hook function

Register hook functions in the functions array, keyed by hook name. Each function takes a single $args array and returns an array of variables to write back, or nothing when it only observes.

plugins/my_plugin/init.php
$kleeja_plugin['my_plugin']['functions'] = [
    // change a variable in the caller's scope
    'Saaheader_links_func' => function ($args) {
        $extra = $args['extra'] . '<meta name="generator" content="Kleeja">';

        return compact('extra');
    },

    // observe only — no return value
    'ok_added_users_register' => function ($args) {
        my_plugin_log('new user: ' . kleeja_html_decode($args['name']));
    },
];
Hook functions are closures, so they do not inherit the caller's scope. Read core state from $args, and pull globals in explicitly with global $config; when you need them.

The return value matters:

ReturnEffect
compact('var') or ['var' => $value]Overwrites $var in the calling scope.
[], null, or no returnLeaves the caller untouched.
A new key not present in the callerCreates that variable in the calling scope.

Priority and chaining

plugin_priority controls order. The loader sorts hooks from high priority to low, so a plugin with priority 10 runs before one with priority 0.

Hooks chain: run() merges each function's return into $args before calling the next one. A later plugin therefore sees the values an earlier plugin produced, not the original ones.

Core calls extract() only after the last function of the hook has run. A later plugin that reads a value with global $x still sees the old value, so a function that changes a global for the others sets the global and also returns it.

Give plugins distinct priorities where order matters. The loader keys its internal plugin list by priority, so two plugins sharing a value overwrite each other in that list. Their hooks still run, but the bookkeeping is ambiguous.

Dynamic hook names

Some hooks are built from a request variable, which lets a plugin claim a page name that does not exist in core. The admin panel builds three of them from the requested cp value:

admin/index.php
extract(runHook("require_admin_page_begin_{$go_to}", get_defined_vars()));
extract(runHook("require_admin_page_end_{$go_to}", get_defined_vars()));
extract(runHook("not_exists_{$go_to}", get_defined_vars()));

Hook reference

A selection of the most useful hooks. Search the codebase with grep -rn "runHook(" --include="*.php" . for the full list, and check every hook of an old plugin against it: a hook that core no longer fires fails silently.

HookLocationFires when
boot_commonincludes/common.phpConfig, database, template engine and current user are ready. The language is not loaded yet.
end_commonincludes/common.phpBootstrap has finished, before the page script runs.
Saaheader_links_funcincludes/functions_display.phpHeader menus are built. Add meta tags via $extra, and menu items via $top_menu or $side_menu.
Saaheader_funcincludes/functions_display.phpHeader HTML is rendered, before it is echoed.
Saafooter_funcincludes/functions_display.phpFooter variables are assigned, before rendering.
print_Saafooter_funcincludes/functions_display.phpFooter HTML is rendered, before it is echoed.
begin_index_pageindex.phpThe upload page starts. Replace the uploader by changing $uploadingMethodClass.
end_index_pageindex.phpThe upload page finishes.
begin_go_pagego.phpA go.php request starts.
default_go_pagego.phpThe requested go value matches no core page.
begin_download_pagedo.phpA download request starts.
down_go_pagedo.phpThe download page is about to be displayed.
begin_usrcp_pageucp.phpA user control panel request starts.
login_after_submitucp.phpA login form has been submitted.
ok_added_users_registerucp.phpA new user row has been inserted.
begin_admin_pageadmin/index.phpThe admin panel starts, after extensions are discovered. Add pages to $adm_extensions and icons to $ext_icons.
end_admin_pageadmin/index.phpEverything is ready to render. Set $extra_admin_header_code or $extra_admin_footer_code.
admin_help_guidesincludes/adm/s_help.phpThe Help page builds its guides. Add yours to $help_guides.
kleeja_begin_send_mail_funcincludes/functions.phpAn email is ready. Send it yourself and set $sending_mail_handled and $mail_sent.
kleeja_send_mailincludes/functions.phpKleeja is about to send an email with mail().
kleeja_mail_template_funcincludes/functions.phpThe HTML of an email is rendered. Change $template_name and $template_path to use your own template.
kleeja_fetch_file_startincludes/FetchFile.phpA remote fetch begins.
style_parse_funcincludes/style.phpA template is compiled. Rewrite $html to inject markup.
style_parse_func_step_2includes/style.phpA template has compiled to PHP. Add regex => replacement pairs to $rep for your own tags.
delete_cache_funcincludes/functions.phpA cache entry is being cleared.

The database

Kleeja 4 talks to MySQL, MariaDB or SQLite through PDO. The global $SQL is a KleejaDatabase from includes/pdo.php. Write a query as an array and pass it to $SQL->build(), with every value in BIND and a :name placeholder in its place.

plugins/my_plugin/init.php
global $SQL, $dbprefix;

$result = $SQL->build([
    'SELECT'   => 'f.id, f.name, f.size',
    'FROM'     => "{$dbprefix}files f",
    'WHERE'    => 'f.user = :user AND f.type IN (:types)',
    'ORDER BY' => 'f.id DESC',
    'LIMIT'    => '10',
    'BIND'     => ['user' => $user_id, 'types' => ['jpg', 'png']],
]);

while ($row = $SQL->fetch($result)) {
    // ...
}

$SQL->freeresult($result);

The keys are the SQL words themselves: SELECT, FROM, JOINS, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT, INSERT with INTO and VALUES, UPDATE with SET, and DELETE. An array value in BIND expands to a list for IN, and an empty array matches no rows. A placeholder can appear more than once, and a value whose placeholder is missing from the query is dropped.

MethodPurpose
build(array $query)Run a query array. Returns a PDOStatement, or false on error.
query(string $sql, array $params = [])Run raw SQL with the same placeholders, for example a CREATE TABLE.
fetch($result)The next row as an associative array, or false.
freeresult($result)Free a result. Call it before another query changes the same table on SQLite.
num_rows($result)The number of rows of a SELECT.
insert_id()The id of the last inserted row.
affected()The number of rows the last query changed.
get_error()The code and message of the last error.
$SQL->escape() and $SQL->real_escape() still exist for old plugins, but they are deprecated. Bind every value instead of building SQL strings, and encode text for HTML with kleeja_html_encode().

Kleeja stores text HTML-encoded and templates print it as it is. Encode a value once on the way into the database with kleeja_html_encode(), and print stored text with kleeja_html_display(). That function decodes every level of encoding, then encodes once, so text that was encoded twice when it was saved still shows correctly. For plain text, like an email subject, use kleeja_html_decode().

Custom tables

Write CREATE TABLE queries in the MySQL dialect and prefix the table name with {$dbprefix}. On SQLite, Kleeja converts the query with the regular expressions in KleejaDatabase::SQLITE_TYPES.

The conversion replaces every int in the query with INTEGER, even inside a name, so a column called point becomes poINTEGER. It also breaks UNIQUE KEY name (column) lines. Keep int out of your table and column names, put UNIQUE on the column itself, and create the table on both databases before you publish.

Adding settings to the admin panel

Kleeja stores settings in the config table. A setting with an option value appears in Admin → Settings, grouped into a tab by its type.

Registering settings

Use add_config_r() in the install callback. Set type to your plugin's slug so the settings land in their own tab, and pass plg_id so they disappear from the tab list when the plugin is disabled.

plugins/my_plugin/init.php
add_config_r([
    'my_plugin_mode' => [
        'value'  => 'fast',
        'html'   => configField('my_plugin_mode', 'select', [
            'Fast'     => 'fast',
            'Accurate' => 'accurate',
        ]),
        'plg_id' => $plg_id,
        'type'   => 'my_plugin',
        'order'  => '1',
    ],
]);

configField() from includes/functions_display.php generates the input HTML in Kleeja's template syntax:

TypeRenders
textA text input bound to {con.name}. This is the default.
yesnoA pair of radio buttons using the YES and NO language strings.
selectA dropdown built from the $select_options array, in ['Label' => 'value'] form.

You can also write the html yourself. The settings page adds class="form-control" to every <input> and <select>, so leave the class out.

Naming the settings tab

The settings tab reads its label from the language key CONFIG_KLJ_MENUS_<TYPE>, uppercased. Add that string in the install callback, and each setting's label as <NAME> uppercased.

plugins/my_plugin/init.php
add_olang([
    'CONFIG_KLJ_MENUS_MY_PLUGIN' => 'My Plugin settings',
    'MY_PLUGIN_MODE'             => 'Processing mode',
], 'en', $plg_id);

Without CONFIG_KLJ_MENUS_MY_PLUGIN the tab still appears, labelled Other settings.

Reading settings at runtime

Settings are cached and exposed through the global $config array:

plugins/my_plugin/init.php
$kleeja_plugin['my_plugin']['functions'] = [
    'end_index_page' => function ($args) {
        global $config;

        if ($config['my_plugin_mode'] === 'fast') {
            // ...
        }
    },
];

Text settings are stored HTML-encoded. Print them with kleeja_html_display(), and decode them with kleeja_html_decode() before you use them as plain text.

Language strings

Plugin strings live in the lang table and are exposed through the global $olang array, next to core's $lang. Templates read them with {olang.KEY}.

FunctionSignaturePurpose
add_olang()add_olang(array $words, string $lang, int $plg_id)Insert strings for one language.
update_olang()update_olang(string $name, string $value, string $lang)Change one string.
delete_olang()delete_olang($words, $lang, int $plg_id)Delete strings. Pass null words with a plg_id to remove all of the plugin's.

Call add_olang() once per language you ship:

plugins/my_plugin/init.php
add_olang(['MY_PLUGIN_TITLE' => 'My Plugin'], 'en', $plg_id);
add_olang(['MY_PLUGIN_TITLE' => 'إضافتي'], 'ar', $plg_id);
The trans column holds up to 255 characters on MySQL. A longer string fails the install with "Data too long" on MySQL only, because SQLite does not check the length. Keep each string short, and put long texts such as help guides in the plugin's own language files.

Kleeja ships in English and Arabic, so add every string in both languages, and make every page you add work in both directions.

Templates and styles

Kleeja renders pages through the $tpl object. display($template_name, $style_path) compiles a template into cache/ and returns the HTML, where $style_path is an absolute directory that overrides the active style.

A plugin can ship its own templates and point Kleeja at them. To replace the admin theme, override the admin path variables from the end_common hook:

plugins/my_admin_theme/init.php
$kleeja_plugin['my_admin_theme']['functions'] = [
    'end_common' => function ($args) {
        if (! defined('IN_ADMIN')) {
            return;
        }

        global $config;

        $args['STYLE_PATH_ADMIN_ABS']   = PATH . 'plugins/my_admin_theme/theme/';
        $args['DEFAULT_PATH_ADMIN_ABS'] = PATH . 'plugins/my_admin_theme/theme/';
        $args['DEFAULT_PATH_ADMIN']     = $config['siteurl'] . 'plugins/my_admin_theme/theme/';
        $args['STYLE_PATH_ADMIN']       = $config['siteurl'] . 'plugins/my_admin_theme/theme/';

        return $args;
    },
];

The *_ABS variables are filesystem paths used to locate template files; the others are URLs used inside templates to load CSS, JavaScript and images. The theme must ship every admin_*.html template, because the built-in admin/Damask/ theme is no longer used.

To inject markup into an existing template instead of replacing it, hook style_parse_func and rewrite $html before compilation, or Saaheader_func and rewrite $header after rendering. To add a tag of your own, hook style_parse_func_step_2 and add a regex => replacement pair to $rep. Kleeja checks the compiled result, and shows a template error when your replacement produces invalid PHP.

The admin theme and the bootstrap style use Bootstrap 5.3 and Font Awesome from includes/static_shared_files/. Write the markup of your admin pages for Bootstrap 5.3 so they match the rest of the control panel.

Adding new pages

A front-end page

go.php fires default_go_page when the go parameter matches no core page. Set $no_request to false to claim the request, then choose a template with $stylee and $styleePath, and a page title with $titlee.

plugins/my_plugin/init.php
$kleeja_plugin['my_plugin']['functions'] = [
    'default_go_page' => function ($args) {
        if (g('go') !== 'mypage') {
            return;
        }

        global $tpl;

        $tpl->assign('my_message', 'Hello from my plugin');

        $no_request = false;
        $stylee     = 'my_page';
        $styleePath = PATH . 'plugins/my_plugin/templates/';
        $titlee     = 'My page';

        return compact('no_request', 'stylee', 'styleePath', 'titlee');
    },
];

Place the template at plugins/my_plugin/templates/my_page.html. Kleeja then serves the page at go.php?go=mypage, and at go-mypage.html when pretty URLs are on.

Every link has two forms, one for each value of $config['mod_writer']. Build links with kleeja_get_link(), or branch on mod_writer like core does. To give a page a URL of its own, add a rewrite rule from the install callback with add_to_serve_rules(), and remove it in the uninstall callback with remove_from_serve_rules():

plugins/my_plugin/init.php
add_to_serve_rules("'^my-page\\.html$' => ['file' => 'go.php', 'args' => 'go=mypage'],", 'my_plugin');

The rules are written to plugins_rules.php, which serve.php reads on every pretty URL.

remove_from_serve_rules() writes an empty plugins_rules.php when the file is missing, and every pretty URL then fails. Check that the file exists and is writable before you call it.

An admin page

The admin panel builds its menu from the PHP files in includes/adm/, and resets any other cp value to the dashboard. Add your page name to $adm_extensions in the begin_admin_page hook. When the requested page has no file in includes/adm/, Kleeja fires not_exists_{$go_to} and includes whatever path you assign to $include_alternative.

plugins/my_plugin/init.php
$kleeja_plugin['my_plugin']['functions'] = [
    'begin_admin_page' => function ($args) {
        $adm_extensions = $args['adm_extensions'];
        $ext_icons      = $args['ext_icons'];

        $adm_extensions[]       = 'my_plugin';
        $ext_icons['my_plugin'] = 'puzzle-piece';

        return compact('adm_extensions', 'ext_icons');
    },

    'not_exists_my_plugin' => function ($args) {
        $include_alternative = __DIR__ . '/admin_page.php';

        return compact('include_alternative');
    },
];

Your included file sets $stylee and $styleePath, exactly like a core admin page. Reach the page at admin/?cp=my_plugin. The menu shows the R_MY_PLUGIN string from $lang or $olang, and the icon is a Font Awesome name without the fa- prefix.

plugins/my_plugin/admin_page.php
<?php
// not for directly open
if (! defined('IN_ADMIN')) {
    exit();
}

$H_FORM_KEYS = kleeja_add_form_key('my_plugin');

if (ip('submit') && ! kleeja_check_form_key('my_plugin')) {
    kleeja_admin_err($lang['INVALID_FORM_KEY']);
}

$stylee     = 'admin_my_plugin';
$styleePath = __DIR__ . '/templates/';

Protect every form with kleeja_add_form_key() and kleeja_check_form_key(), and every link that changes data with the _get variants. Check permissions with user_can(), or with $userinfo['founder'] for actions only founders may take.

When your plugin only needs a settings screen, skip the custom page and set settings_page in the information array to cp=options&smt=my_plugin. The plugin list then shows a settings button that links straight to your settings tab.

A help guide

The control panel has a Help page at admin/?cp=s_help, and the help button of each page opens the guide of that page. A plugin adds its guide with the admin_help_guides hook, keyed by the plugin name. Kleeja then shows the plugin's icon next to it. An installed plugin without a guide gets an entry with its description and a link to its settings.

plugins/my_plugin/init.php
'admin_help_guides' => function ($args) {
    $help_guides = $args['help_guides'];

    $help_guides['my_plugin'] = [
        'title'    => ['en' => 'My Plugin', 'ar' => 'إضافتي'],
        'intro'    => ['en' => 'What the plugin does, in a sentence or two.', 'ar' => '...'],
        // the help button of ?cp=my_plugin opens this guide
        'page'     => 'my_plugin',
        'link'     => './?cp=my_plugin',
        'sections' => [
            ['type' => 'steps', 'items' => ['en' => ['Open the settings.', 'Save.'], 'ar' => ['...', '...']]],
            ['type' => 'faq', 'items' => [['q' => 'A question?', 'a' => 'The answer.']]],
        ],
    ];

    return compact('help_guides');
},

A section type is features, steps, tips, warnings, faq, text or code. Its items are either a list of translated items or one list per language. Keep the words of a guide in the plugin's own language files, such as language/help_en.php and language/help_ar.php, rather than in the lang table. The Help page itself has a guide for plugin developers with a longer example.

Managing plugins

Founders manage plugins in Admin → Plugins. The page has four tabs: installed plugins, folders present on disk but not yet installed, plugins available in the Kleeja store, and installed plugins that have a newer version in the store.

ActionEffect
InstallInserts the plugins row, runs install, shows the first_run message.
DisableSets plg_disabled = 1. The plugin stays installed but is not loaded, and its settings tab disappears.
EnableClears plg_disabled.
UninstallRuns uninstall, deletes the plugins row. Files stay on disk.
Delete folderRemoves the plugin folder from plugins/.
UploadExtracts an uploaded .zip into plugins/.
DownloadFetches and extracts a plugin from the store, with automatic rollback on failure. Also updates an installed plugin.
Only founder accounts can install, uninstall, enable, disable or upload plugins. Every action is protected by a CSRF form key.

Code that needs to manage plugins outside the admin page can call the static methods of the Plugins class. They do not check the permissions of the current user, and the hooks of a newly enabled plugin run from the next request.

MethodEffect
Plugins::installFromStore($name)Downloads, installs and enables a plugin in one call, skipping the steps already done.
Plugins::download($name)Downloads a plugin from the store into plugins/, or updates its files, without installing it.
Plugins::install($name)Installs a plugin that is already in plugins/.
Plugins::enable($name), Plugins::disable($name)Enables or disables an installed plugin.
Plugins::lastError()Explains why the last of these calls returned false.

Packaging and distribution

Ship a plugin as a .zip archive containing one top-level folder. Administrators upload it in Admin → Plugins, and Kleeja extracts it straight into plugins/.

Terminal
zip -r my_plugin.zip my_plugin -x "*.git*"

The top-level folder may carry a version, as in the archives that GitHub builds for a release. Kleeja renames my-plugin-1.0/ to my_plugin after it extracts the archive, turning dashes into underscores. Add an icon.png to the plugin folder for a custom icon in the plugin list, and an index.html to block directory listings on servers that allow them.

Kleeja also reads a public catalog, so listed plugins install with one click. Kleeja 4 reads kleeja-4-catalog.json, where each plugin has an entry like this one:

kleeja-4-catalog.json
{
  "type": "plugin",
  "name": "my_plugin",
  "icon": "https://raw.githubusercontent.com/you/my_plugin/main/icon.png",
  "title": { "en": "My Plugin", "ar": "إضافتي" },
  "description": { "en": "Does something useful", "ar": "تقوم بشيء مفيد" },
  "developer": "Your Name",
  "website": "https://github.com/you/my_plugin",
  "kleeja_version": { "min": "4.0.0", "max": "4.9" },
  "file": {
    "version": "1.0",
    "url": "https://github.com/you/my_plugin/archive/1.0.zip"
  }
}

The store hides plugins whose kleeja_version range does not include the running Kleeja. An optional preview object, in the same form as file, replaces file on sites in development mode, so you can test a release before you publish it.

The Kleeja store catalog.

Debugging

Add define('DEV_STAGE', true); to config.php to unlock the debugging tools. Templates are compiled again on every request, all PHP errors are reported and displayed, dd() is available, and kleeja_log() writes to cache/kleeja_log.log.

The page footer shows the generation time and query count, and administrators get a Debug Info link that opens the page again with ?debug. The debug panel lists:

  • every query of the page, with its time and bound values,
  • each hook that has functions, with the plugins that registered them in the order they run,
  • memory use, the PHP version and the database driver.

The hook list is the fastest way to confirm your hook names are spelled correctly. To test store entries that are not marked compatible with your Kleeja version, add define('IGNORE_STORE_COMPATIBILITY', true); as well.

Recovering from a broken plugin

When a plugin breaks the site badly enough that the admin panel is unreachable, turn the whole system off from config.php:

config.php
define('STOP_PLUGINS', true);

The loader returns immediately, no plugin is included, and the footer reports Hook System: Disabled. Remove the line once you have disabled or deleted the offending plugin.

init.php is included on every single request. Keep it to declarations, and never run queries, network calls or file scans at the top level, or you slow down every page on the site.

Complete example

A minimal plugin that adds a setting and appends a custom footer note.

plugins/footer_note/init.php
<?php
// prevent illegal run
if (! defined('IN_PLUGINS_SYSTEM')) {
    exit();
}

$kleeja_plugin['footer_note']['information'] = [
    'plugin_title' => [
        'en' => 'Footer Note',
        'ar' => 'ملاحظة التذييل',
    ],
    'plugin_developer' => 'Your Name',
    'plugin_version'   => '1.0',
    'plugin_description' => [
        'en' => 'Appends a custom note to the site footer',
        'ar' => 'تضيف ملاحظة مخصصة إلى تذييل الموقع',
    ],
    'plugin_kleeja_version_min' => '4.0.0',
    'plugin_kleeja_version_max' => '4.9',
    'plugin_priority'           => 0,
    'settings_page'             => 'cp=options&smt=footer_note',
];

$kleeja_plugin['footer_note']['first_run']['en'] = 'Set your note in Admin → Settings → Footer note.';

$kleeja_plugin['footer_note']['install'] = function ($plg_id) {
    add_config_r([
        'footer_note_text' => [
            'value'  => '',
            'html'   => configField('footer_note_text'),
            'plg_id' => $plg_id,
            'type'   => 'footer_note',
            'order'  => '1',
        ],
    ]);

    add_olang([
        'CONFIG_KLJ_MENUS_FOOTER_NOTE' => 'Footer note',
        'FOOTER_NOTE_TEXT'             => 'Note text',
    ], 'en', $plg_id);

    add_olang([
        'CONFIG_KLJ_MENUS_FOOTER_NOTE' => 'ملاحظة التذييل',
        'FOOTER_NOTE_TEXT'             => 'نص الملاحظة',
    ], 'ar', $plg_id);
};

$kleeja_plugin['footer_note']['update'] = function ($old_version, $new_version) {
    // no migrations yet
};

$kleeja_plugin['footer_note']['uninstall'] = function ($plg_id) {
    delete_config(['footer_note_text']);

    if ($plg_id > 0) {
        delete_olang(null, null, $plg_id);
    }
};

$kleeja_plugin['footer_note']['functions'] = [
    'print_Saafooter_func' => function ($args) {
        global $config;

        if (empty($config['footer_note_text'])) {
            return;
        }

        // the setting is stored HTML-encoded, kleeja_html_display() encodes it exactly once
        $note   = '<p class="footer-note">' . kleeja_html_display($config['footer_note_text']) . '</p>';
        $footer = str_replace('</body>', $note . '</body>', $args['footer']);

        return compact('footer');
    },
];

Upgrading a plugin from Kleeja 3

Most of the plugin system works as it did in Kleeja 3. Check these points when you port a plugin:

  • PHP 8.2. Kleeja 4 requires PHP 8.2, so code that relied on removed PHP features or loose types must be fixed.
  • Version limits. Raise plugin_kleeja_version_max. A maximum of 3.9 blocks installation on Kleeja 4.
  • Queries. The database layer is PDO. Move values out of the SQL strings into BIND, and replace $SQL->escape() and $SQL->real_escape(). See The database.
  • The update callback. Replace config helpers and delete_cache() there with direct queries. See Update callback.
  • Hooks. Check every hook name against the core, because a function attached to a hook that Kleeja no longer fires never runs, and nothing reports it.
  • The admin theme. The control panel uses the Damask theme with Bootstrap 5.3, so admin templates written for the old Bootstrap 4 theme need new markup.
  • Text encoding. Print stored text with kleeja_html_display(), so text that was encoded twice does not show entities such as &amp;quot;.
  • Help. Move help texts to the Help page with the admin_help_guides hook. See A help guide.

API reference

Helper functions available to plugins, all defined in includes/functions.php unless noted.

FunctionSignature
add_config()add_config(string $name, string $value, int $order = 0, string $html = '', string $type = '0', int $plg_id = 0, bool $dynamic = false): bool
add_config_r()add_config_r(array $configs): bool
update_config()update_config(string $name, string $value, bool $escape = true, int $group = 0): bool
delete_config()delete_config(string|array $name): bool
get_config()get_config(string $name): ?string, reads the value from the database, not the cache
add_olang()add_olang(array $words = [], string $lang = 'en', int $plg_id = 0): void
update_olang()update_olang(string $name, string $value, string $lang = 'en'): bool
delete_olang()delete_olang(string|array|null $words = '', string|array|null $lang = 'en', int $plg_id = 0): bool
delete_cache()delete_cache(string|array $name, bool $all = false): bool
g(), p()g(string $name, string $type = 'str', string $default = ''): string|int, reads $_GET or $_POST, HTML-encoded
ig(), ip()ig(string $name): bool, checks that a $_GET or $_POST value is set
user_can()user_can(string $acl_name, int $group_id = 0): bool
kleeja_html_encode()kleeja_html_encode(?string $text): string
kleeja_html_decode()kleeja_html_decode(?string $text): string
kleeja_html_display()kleeja_html_display(?string $text): string
add_to_serve_rules()add_to_serve_rules(string|array $rules, string $unique_id = ''): bool
remove_from_serve_rules()remove_from_serve_rules(string $unique_id): bool
send_mail()send_mail(string $to, string|array $body, string $subject, string $fromAddress, string $fromName, string $bcc = ''): bool
kleeja_get_link()kleeja_get_link(string $pid, array $extra = []): string — includes/functions_display.php
configField()configField(string $name, string $type = 'text', array $select_options = []): string — includes/functions_display.php
runHook()runHook(string $hookName, array $definedVariables): array — includes/plugins.php

Constants

ConstantMeaning
IN_PLUGINS_SYSTEMDefined while plugins load. Guard every init.php with it.
STOP_PLUGINSDefined in config.php to disable the whole plugin system.
KLEEJA_PLUGINS_FOLDERThe plugins directory name, plugins by default.
KLEEJA_VERSIONThe running Kleeja version, used for compatibility checks.
PATHAbsolute filesystem path to the Kleeja root, with a trailing slash.
DEV_STAGEEnables debugging output and disables template caching.
IGNORE_STORE_COMPATIBILITYDefined in config.php to list and download store items whatever their Kleeja version range.
SQL_NO_ERRORSDefined while a plugin installs. Failing queries return false instead of showing an error page.
IN_ADMINDefined while the admin panel renders.
ACP_STYLE_NAMEThe admin theme folder, Damask.