Kleeja Logo
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 3.x. The loader lives in 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/
plugins/
└── my_plugin/
    ├── init.php      # required — the whole plugin definition
    ├── icon.png      # optional — shown in the plugin list
    ├── index.html    # optional — blocks directory listing
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 all six keys even when a callback is empty. The loader and the admin manager read 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.

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 auto-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.
plugin_kleeja_version_maxstringHighest supported Kleeja version. Use 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 gear icon next to the plugin.
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' => '3.2.0',
    'plugin_kleeja_version_max' => '3.9',
    'plugin_priority'           => 0,
    'settings_page'             => 'cp=options&amp;smt=my_plugin',
];
Version limits are checked once, at install time, with 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.

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. Test the install callback on a scratch database 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.

plugins/my_plugin/init.php
$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.

plugins/my_plugin/init.php
$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);
    }
};
Passing 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.

includes/common.php
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:

do.php
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.

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: ' . $args['username']);
    },
];
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.

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

HookLocationFires when
boot_commonincludes/common.php:250Config, database and template engine are ready.
end_commonincludes/common.php:396Bootstrap has finished, before the page script runs.
Saaheader_links_funcincludes/functions_display.php:104Header menus are built — add meta tags via $extra, or menu items via $top_menu.
Saaheader_funcincludes/functions_display.php:150Header HTML is rendered, before it is echoed.
Saafooter_funcincludes/functions_display.php:235Footer variables are assigned, before rendering.
print_Saafooter_funcincludes/functions_display.php:241Footer HTML is rendered, before it is echoed.
begin_index_pageindex.php:30The upload page starts.
end_index_pageindex.php:181The upload page finishes.
begin_go_pagego.php:21A go.php request starts.
default_go_pagego.php:709The requested go value matches no core page.
begin_download_pagedo.php:17A download request starts.
down_go_pagedo.php:396The download page is about to be displayed.
begin_usrcp_pageucp.php:18A user control panel request starts.
login_after_submitucp.php:67A login form has been submitted.
ok_added_users_registerucp.php:282A new user row has been inserted.
begin_admin_pageadmin/index.php:221The admin panel starts, after extensions are discovered.
end_admin_pageadmin/index.php:404Everything is ready to render — set $extra_admin_header_code or $extra_admin_footer_code.
kleeja_send_mailincludes/functions.php:242Kleeja is about to send an email.
kleeja_fetch_file_startincludes/FetchFile.php:79A remote fetch begins.
style_parse_funcincludes/style.php:116A template is compiled — rewrite $html to inject markup.
delete_cache_funcincludes/functions.php:281A 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.

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.

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') {
            // ...
        }
    },
];

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);

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:

plugins/zaki_admin_theme/init.php
$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.

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.

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.

plugins/my_plugin/init.php
$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.

plugins/my_plugin/init.php
'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.

When your plugin only needs a settings screen, skip the custom page and set settings_page in the information array to cp=options&amp;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.

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 remote catalog, with automatic rollback on failure.
Only founder accounts can install, uninstall, enable, disable or upload plugins. Every action is protected by a CSRF form key.

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*"

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:

The Kleeja store catalog.

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:

includes/plugins.php
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:

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.

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' => '3.2.0',
    'plugin_kleeja_version_max' => '3.9',
    'plugin_priority'           => 0,
    'settings_page'             => 'cp=options&amp;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.

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
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 = []): stringincludes/functions_display.php
runHook()runHook(string $hookName, array $definedVariables): arrayincludes/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.
IN_ADMINDefined while the admin panel renders.