| .. | |||
| README.md | Initial commit · Alex | 01/10/2026 09:20 | 9.9 KB |
A plugin is a folder in plugins/. It can add public pages, admin pages,
database tables and behaviour without editing a single ZnoteX file — which
is the whole point: the next update replaces ZnoteX and leaves your work alone.
Install, update, enable and disable them in Admin Panel → Plugins.
Installing one: download it, unzip the folder into plugins/, reload the
panel, press Install. That runs its install.sql and switches it on. ZnoteX
never downloads a plugin by itself — a plugin is PHP that runs on every page of
your site, so putting the files there stays a deliberate act.
Updating one: replace the folder with the newer version. If its
plugin.json carries a higher version than the one recorded at install time,
an Update button appears and re-runs install.sql. That is why the file has
to be idempotent — see below.
plugins/my_plugin/
plugin.json name, version, author, description [required]
plugin.php registers hooks and helpers [optional]
pages/<page>.php public page at page.php?plugin=my_plugin&p=<page>
admin/<mod>.php admin page, listed in the sidebar
install.sql tables, created when the plugin is enabled
assets/ css, js, images
Only plugin.json is required. A plugin that just reacts to a hook is a
plugin.json and a plugin.php, nothing else.
The folder name is the plugin's identity: lowercase letters, digits, - and
_. A folder starting with _ is ignored, which is how you park one.
{
"name": "My Plugin",
"version": "1.0.0",
"author": "You",
"description": "One or two sentences shown in the admin panel.",
"url": "https://example.com",
"requires": {
"znotex": ">=2.0.0 <3.0.0",
"php": ">=8.1",
"api": "^1.0",
"extensions": ["json"]
}
}
The plugin version is independent from theme and ZnoteX versions. The
requires object declares which host environment the plugin supports.
ZnoteX refuses to install, enable or load an incompatible plugin. The legacy
string form, such as "requires": "2.0.0", remains supported and means
ZnoteX 2.0.0 or newer.
Loaded on every request while the plugin is enabled, right after the database and the settings are up. So:
A plugin.php that throws is skipped and logged. One broken plugin does not
take the site down.
$api = znote_plugin_api('my_plugin');
$api->on('shop.purchased', function (array $data): void {
});
$value = $api->setting('enabled', '1');
$cache = $api->cache('catalogue', 300);
$url = $api->url('shop');
$asset = $api->asset('style.css');
$db = $api->database();
The API also exposes config(), setSetting(), dispatch(),
filter(), collect(), allows(), apiVersion() and
znoteVersion(). Plugin settings and cache keys are automatically isolated
under the plugin name.
Drop pages/shop.php into your plugin and it is live at
page.php?plugin=my_plugin&p=shop. Nothing to register.
The file is a fragment: page.php has already run engine/init.php and
opened the theme, so $config, $user_data and the mysql_* helpers are all
there, and the active theme wraps whatever you print. Do not include
init.php, and do not print a header or a footer.
?p= is matched against the files that actually exist, so it can never reach
anything outside pages/. A page of a plugin that is not installed and enabled
returns 404.
plugins/.htaccess blocks direct requests to anything but assets/, so nobody
can run one of your files outside page.php or read your install.sql. Guard
your pages anyway — someone will run ZnoteX on a server that ignores
.htaccess:
if (!isset($config)) { http_response_code(403); die('Direct access denied.'); }
Link to one with znote_plugin_url('my_plugin', 'shop'), and to a file in
assets/ with znote_plugin_asset('my_plugin', 'style.css').
The <body> gets a page_my_plugin_shop class, so a theme can style your page
from CSS alone.
Ship one and the plugin gets a configuration page for free - a Settings button next to it in Admin Panel > Plugins - instead of hand-coding a form:
{
"fields": [
{"key": "api_key", "label": "API key", "type": "text", "default": ""},
{"key": "enabled", "label": "Enabled", "type": "bool", "default": "1"},
{"key": "mode", "label": "Mode", "type": "select", "default": "test",
"options": {"test": "Test", "live": "Live"}},
{"key": "max_items", "label": "Max items", "type": "int", "default": "10", "min": 1, "max": 100},
{"key": "notes", "label": "Notes", "type": "textarea", "default": ""},
{"key": "webhook_secret", "label": "Webhook secret", "type": "password", "default": ""}
]
}
Types: text, textarea, password, bool, int (with optional min/max),
select and checklist (both need options). Every field is optional except
key and type.
Saved values live under the same namespace $api->setting() already reads, so
plugin.php sees exactly what the generated form saved:
$mode = $api->setting('mode', 'test');
Drop admin/orders.php into your plugin and it appears in the admin sidebar.
It is written exactly like a built-in module — see admin/modules/_template.php
— with the same docblock header and the same acp_* helpers:
<?php
/**
* Title: Orders
* Icon: fa-shopping-cart
* Group: Economy
* Order: 60
* Description: One line under the page title.
*/
Its key is namespaced my_plugin__orders, so a plugin can never shadow a core
module by picking the same filename. Use that key with acp_redirect().
Run on Install and again on every Update, statement by statement.
Every statement must be idempotent — CREATE TABLE IF NOT EXISTS and the
like. ZnoteX does not track which statements already ran, so an update simply
runs the whole file again: whatever the new version added gets created, and what
was already there is left alone with its data intact.
Neither Disable nor Uninstall ever drops a table. Losing a player's data because someone clicked a button would be the wrong default. Removing a plugin for good is deleting its folder and dropping its tables yourself.
The version in plugin.json is what the update check compares, with PHP's
version_compare(). Raise it whenever you ship a change that needs
install.sql re-run, and keep it plain: 1.0.0, 1.1.0, 2.0.0.
// React to something. Return value ignored.
znote_hook_register('shop.purchased', function (array $data) { ... });
// Change a value. Gets the current value, returns the new one.
znote_hook_register('shop.price', function ($price, array $data) { return $price - 5; });
// Add markup. Whatever you return is inserted into the page.
znote_hook_register('page.footer', function () { return '<div>...</div>'; });
An optional third argument is the priority, default 10, lowest first.
A callback that throws is caught and logged, and the site carries on. That is the difference between an extension point and a landmine.
| Hook | Kind | When | $data |
|---|---|---|---|
plugins.loaded | notify | every plugin is loaded | — |
page.head | collect | before </head> | — |
page.footer | collect | before </body> | — |
shop.price | filter | before a purchase is priced | account_id, offer_id, offer |
shop.purchased | notify | after the points are taken | account_id, offer_id, type, itemid, count, points |
account.registered | notify | after an account is created | name, email |
character.created | notify | after a character is created | name, account_id, vocation |
character.renamed | notify | after an admin renames a character | player_id, old_name, new_name |
payment.completed | notify | after a real-money payment is recorded | provider, reference, provider_reference, account_id, price, currency |
shop.price is the one to reach for when you want to change what something
costs. Its result is used for all three of the affordability check, the points
actually deducted and the shop log, so they cannot disagree.
page.head and page.footer are injected into the theme's own output, so they
work with themes written long before your plugin existed — including ones that
never call a plugin function.
Publish one and other plugins can extend yours:
znote_hook('coupon.redeemed', array('code' => $code, 'account_id' => $id));
Adding one is two lines in the core file, and hooks with no listeners cost almost nothing. Open an issue rather than forking.
plugins/shop_coupons/ is a working plugin that uses every one of these:
a public page, an admin page, its own tables, a filter hook that discounts a
purchase, a notify hook that consumes the discount afterwards, and a collect
hook that puts a banner in the footer. Read it top to bottom — it is commented
as a tutorial rather than as production code.
plugin.json is valid JSON.plugin.php prints nothing and runs no queries at load.install.sql is idempotent.<?= acp_csrf_field() ?> (admin) or Token::create() (public).esc() or (int).h() or htmlspecialchars().