Plugin Development
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.
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/
└── 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
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.
<?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 */ ];
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.
| Key | Type | Purpose |
|---|---|---|
plugin_title | array | Display name per language code, for example ['en' => 'My Plugin', 'ar' => 'إضافتي']. Falls back to en, then to the first entry. |
plugin_developer | string | Author name, stored in plg_author. |
plugin_version | string | Plugin version. Drives the update callback and the store's update check, so bump it on every release. |
plugin_description | array | Short description per language code. Shown in the plugin list, truncated to 100 characters. |
plugin_kleeja_version_min | string | Lowest supported Kleeja version. Installation fails below it. Leave it empty or 0 for no limit. |
plugin_kleeja_version_max | string | Highest supported Kleeja version. Installation fails above it. Leave it empty or 0 for no limit. |
plugin_priority | int | Load order. Higher numbers run first; 0 is normal. |
settings_page | string | Optional. 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. |
$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_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 & 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.
$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.
$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);
};
$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()andget_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 withunlink()instead.
$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.
$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);
}
};
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.
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.
$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']));
},
];
$args, and pull globals in explicitly with global $config; when you need them.The return value matters:
| Return | Effect |
|---|---|
compact('var') or ['var' => $value] | Overwrites $var in the calling scope. |
[], null, or no return | Leaves the caller untouched. |
| A new key not present in the caller | Creates 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.
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:
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.
| Hook | Location | Fires when |
|---|---|---|
boot_common | includes/common.php | Config, database, template engine and current user are ready. The language is not loaded yet. |
end_common | includes/common.php | Bootstrap has finished, before the page script runs. |
Saaheader_links_func | includes/functions_display.php | Header menus are built. Add meta tags via $extra, and menu items via $top_menu or $side_menu. |
Saaheader_func | includes/functions_display.php | Header HTML is rendered, before it is echoed. |
Saafooter_func | includes/functions_display.php | Footer variables are assigned, before rendering. |
print_Saafooter_func | includes/functions_display.php | Footer HTML is rendered, before it is echoed. |
begin_index_page | index.php | The upload page starts. Replace the uploader by changing $uploadingMethodClass. |
end_index_page | index.php | The upload page finishes. |
begin_go_page | go.php | A go.php request starts. |
default_go_page | go.php | The requested go value matches no core page. |
begin_download_page | do.php | A download request starts. |
down_go_page | do.php | The download page is about to be displayed. |
begin_usrcp_page | ucp.php | A user control panel request starts. |
login_after_submit | ucp.php | A login form has been submitted. |
ok_added_users_register | ucp.php | A new user row has been inserted. |
begin_admin_page | admin/index.php | The admin panel starts, after extensions are discovered. Add pages to $adm_extensions and icons to $ext_icons. |
end_admin_page | admin/index.php | Everything is ready to render. Set $extra_admin_header_code or $extra_admin_footer_code. |
admin_help_guides | includes/adm/s_help.php | The Help page builds its guides. Add yours to $help_guides. |
kleeja_begin_send_mail_func | includes/functions.php | An email is ready. Send it yourself and set $sending_mail_handled and $mail_sent. |
kleeja_send_mail | includes/functions.php | Kleeja is about to send an email with mail(). |
kleeja_mail_template_func | includes/functions.php | The HTML of an email is rendered. Change $template_name and $template_path to use your own template. |
kleeja_fetch_file_start | includes/FetchFile.php | A remote fetch begins. |
style_parse_func | includes/style.php | A template is compiled. Rewrite $html to inject markup. |
style_parse_func_step_2 | includes/style.php | A template has compiled to PHP. Add regex => replacement pairs to $rep for your own tags. |
delete_cache_func | includes/functions.php | A 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.
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.
| Method | Purpose |
|---|---|
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.
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.
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:
| Type | Renders |
|---|---|
text | A text input bound to {con.name}. This is the default. |
yesno | A pair of radio buttons using the YES and NO language strings. |
select | A 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.
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:
$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}.
| Function | Signature | Purpose |
|---|---|---|
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:
add_olang(['MY_PLUGIN_TITLE' => 'My Plugin'], 'en', $plg_id);
add_olang(['MY_PLUGIN_TITLE' => 'إضافتي'], 'ar', $plg_id);
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:
$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.
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.
$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():
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.
$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.
<?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.
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.
'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.
| Action | Effect |
|---|---|
| Install | Inserts the plugins row, runs install, shows the first_run message. |
| Disable | Sets plg_disabled = 1. The plugin stays installed but is not loaded, and its settings tab disappears. |
| Enable | Clears plg_disabled. |
| Uninstall | Runs uninstall, deletes the plugins row. Files stay on disk. |
| Delete folder | Removes the plugin folder from plugins/. |
| Upload | Extracts an uploaded .zip into plugins/. |
| Download | Fetches and extracts a plugin from the store, with automatic rollback on failure. Also updates an installed plugin. |
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.
| Method | Effect |
|---|---|
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/.
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:
{
"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.
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:
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.
<?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 of3.9blocks 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
Damasktheme 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&quot;. - Help. Move help texts to the Help page with the
admin_help_guideshook. See A help guide.
API reference
Helper functions available to plugins, all defined in includes/functions.php unless noted.
| Function | Signature |
|---|---|
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
| Constant | Meaning |
|---|---|
IN_PLUGINS_SYSTEM | Defined while plugins load. Guard every init.php with it. |
STOP_PLUGINS | Defined in config.php to disable the whole plugin system. |
KLEEJA_PLUGINS_FOLDER | The plugins directory name, plugins by default. |
KLEEJA_VERSION | The running Kleeja version, used for compatibility checks. |
PATH | Absolute filesystem path to the Kleeja root, with a trailing slash. |
DEV_STAGE | Enables debugging output and disables template caching. |
IGNORE_STORE_COMPATIBILITY | Defined in config.php to list and download store items whatever their Kleeja version range. |
SQL_NO_ERRORS | Defined while a plugin installs. Failing queries return false instead of showing an error page. |
IN_ADMIN | Defined while the admin panel renders. |
ACP_STYLE_NAME | The admin theme folder, Damask. |