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.How it works
On every request, includes/common.php boots the Plugins singleton. The 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 Plugins::getInstance()->run('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
├── index.html # optional — blocks directory listing
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, update, install and uninstall directly, and a missing key raises a PHP warning. A plugin without an uninstall callback cannot be uninstalled from the admin panel at all — 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 auto-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. |
plugin_kleeja_version_max | string | Highest supported Kleeja version. Use 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 gear icon next to the plugin. |
$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' => '3.2.0',
'plugin_kleeja_version_max' => '3.9',
'plugin_priority' => 0,
'settings_page' => 'cp=options&smt=my_plugin',
];
version_compare() against KLEEJA_VERSION. Set plugin_kleeja_version_max generously — an over-tight maximum blocks installation on newer Kleeja releases even when the plugin still works.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);
};
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.
$kleeja_plugin['my_plugin']['update'] = function ($old_version, $new_version) {
if (version_compare($old_version, '1.1', '<')) {
add_config('my_plugin_timeout', '30', 3, configField('my_plugin_timeout'), 'my_plugin');
}
if (version_compare($old_version, '2.0', '<')) {
update_config('my_plugin_api_key', '');
}
};
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.
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',
]);
foreach (['ar', 'en'] as $language) {
delete_olang(null, $language, $plg_id);
}
};
null as the first argument to delete_olang() with a $plg_id deletes every string that belongs to the plugin in that language. This is the cleanest way to undo add_olang().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
Most core call sites look like this. The hook receives every variable in scope at that point, and extract() writes the returned values back over them.
is_array($plugin_run_result = Plugins::getInstance()->run('end_common', get_defined_vars()))
? extract($plugin_run_result)
: null; //run hook
Newer call sites use the runHook() shorthand, which is equivalent:
extract(runHook('begin_download_page', get_defined_vars()));
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: ' . $args['username']);
},
];
$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.
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:
Plugins::getInstance()->run("require_admin_page_begin_{$go_to}", get_defined_vars());
Plugins::getInstance()->run("require_admin_page_end_{$go_to}", get_defined_vars());
Plugins::getInstance()->run("not_exists_{$go_to}", get_defined_vars());
Hook reference
A selection of the most useful hooks. Search the codebase with grep -rn "run('" --include="*.php" . for the full list.
| Hook | Location | Fires when |
|---|---|---|
boot_common | includes/common.php:250 | Config, database and template engine are ready. |
end_common | includes/common.php:396 | Bootstrap has finished, before the page script runs. |
Saaheader_links_func | includes/functions_display.php:104 | Header menus are built — add meta tags via $extra, or menu items via $top_menu. |
Saaheader_func | includes/functions_display.php:150 | Header HTML is rendered, before it is echoed. |
Saafooter_func | includes/functions_display.php:235 | Footer variables are assigned, before rendering. |
print_Saafooter_func | includes/functions_display.php:241 | Footer HTML is rendered, before it is echoed. |
begin_index_page | index.php:30 | The upload page starts. |
end_index_page | index.php:181 | The upload page finishes. |
begin_go_page | go.php:21 | A go.php request starts. |
default_go_page | go.php:709 | The requested go value matches no core page. |
begin_download_page | do.php:17 | A download request starts. |
down_go_page | do.php:396 | The download page is about to be displayed. |
begin_usrcp_page | ucp.php:18 | A user control panel request starts. |
login_after_submit | ucp.php:67 | A login form has been submitted. |
ok_added_users_register | ucp.php:282 | A new user row has been inserted. |
begin_admin_page | admin/index.php:221 | The admin panel starts, after extensions are discovered. |
end_admin_page | admin/index.php:404 | Everything is ready to render — set $extra_admin_header_code or $extra_admin_footer_code. |
kleeja_send_mail | includes/functions.php:242 | Kleeja is about to send an email. |
kleeja_fetch_file_start | includes/FetchFile.php:79 | A remote fetch begins. |
style_parse_func | includes/style.php:116 | A template is compiled — rewrite $html to inject markup. |
delete_cache_func | includes/functions.php:281 | A cache entry is being cleared. |
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. |
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') {
// ...
}
},
];
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);
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. The bundled zaki_admin_theme plugin replaces the whole admin theme from the end_common hook:
$kleeja_plugin['zaki_admin_theme']['functions'] = [
'end_common' => function ($args) {
if (! defined('IN_ADMIN')) {
return;
}
global $config;
$args['STYLE_PATH_ADMIN_ABS'] = PATH . 'plugins/zaki_admin_theme/zaki/';
$args['DEFAULT_PATH_ADMIN_ABS'] = PATH . 'plugins/zaki_admin_theme/zaki/';
$args['DEFAULT_PATH_ADMIN'] = $config['siteurl'] . 'plugins/zaki_admin_theme/zaki/';
$args['STYLE_PATH_ADMIN'] = $config['siteurl'] . 'plugins/zaki_admin_theme/zaki/';
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.
style_parse_func and rewrite $html before compilation, or Saaheader_func and rewrite $header after rendering.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.
$kleeja_plugin['my_plugin']['functions'] = [
'default_go_page' => function ($args) {
if (g('go', 'str') !== 'mypage') {
return;
}
global $tpl;
$tpl->assign('my_message', 'Hello from my plugin');
$no_request = false;
$stylee = 'my_page';
$styleePath = PATH . 'plugins/my_plugin/templates/';
return compact('no_request', 'stylee', 'styleePath');
},
];
Place the template at plugins/my_plugin/templates/my_page.html. Kleeja then serves the page at go.php?go=mypage.
An admin page
The admin panel builds its menu from the PHP files in includes/adm/. When the requested page is missing, it fires not_exists_{$go_to} and includes whatever path you assign to $include_alternative.
'not_exists_mypanel' => function ($args) {
$include_alternative = PATH . 'plugins/my_plugin/admin_page.php';
return compact('include_alternative');
},
Your included file sets $stylee and $styleePath, exactly like a core admin extension. Reach the page at admin/?cp=mypanel.
settings_page in the information array to cp=options&smt=my_plugin. The admin panel then shows a gear icon that links straight to your settings tab.Managing plugins
Founders manage plugins in Admin → Plugins. The manager lists three groups: installed plugins, folders present on disk but not yet installed, and plugins available in the remote catalog.
| 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 remote catalog, with automatic rollback on failure. |
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*"
Add an icon.png to the plugin folder for a custom icon in the plugin list, and an empty index.html to block directory listings on servers that allow them.
Kleeja also reads a public catalog, so listed plugins install with one click:
Debugging
Add define('DEV_STAGE', true); to config.php to unlock the debugging tools. Template caching is disabled, all PHP errors are reported, and a Debug Info link appears in the page footer for administrators.
The debug page prints the registered hook map and the list of installed plugins, which is the fastest way to confirm your hook names are spelled correctly:
public function getDebugInfo(): array
{
if (!defined('DEV_STAGE')) {
return [];
}
return [
'all_plugins_hooks' => $this->all_plugins_hooks,
'installed_plugins' => $this->installed_plugins,
];
}
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 — 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' => '3.2.0',
'plugin_kleeja_version_max' => '3.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']);
foreach (['ar', 'en'] as $language) {
delete_olang(null, $language, $plg_id);
}
};
$kleeja_plugin['footer_note']['functions'] = [
'print_Saafooter_func' => function ($args) {
global $config;
if (empty($config['footer_note_text'])) {
return;
}
$note = '<p class="footer-note">' . htmlspecialchars($config['footer_note_text']) . '</p>';
$footer = str_replace('</body>', $note . '</body>', $args['footer']);
return compact('footer');
},
];
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 |
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 $words = '', string $lang = 'en', int $plg_id = 0): bool |
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. |
IN_ADMIN | Defined while the admin panel renders. |