ZnoteX

ZnoteX Public
Modern ZnoteX supporting PHP 8.1–8.5, TFS 1.1–1.6, Canary, OTX & OtHire.
Alex Alex Commit Initial commit 01/10/2026 09:20
..
README.md Initial commit · Alex 01/10/2026 09:20 9.9 KB
README.md

ZnoteX plugins

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.


The folder

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.

plugin.json

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


plugin.php

Loaded on every request while the plugin is enabled, right after the database and the settings are up. So:

  • register hooks and declare functions here — that is all it is for;
  • never print anything;
  • keep it cheap. A query here is a query on every page of the site. Do the work inside the hook, where it only runs when it is needed.

A plugin.php that throws is skipped and logged. One broken plugin does not take the site down.

Stable extension API

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


Pages

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.


settings.json

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

Admin pages

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


install.sql

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.

Versioning

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.


Hooks

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

The hooks ZnoteX fires

HookKindWhen$data
plugins.loadednotifyevery plugin is loaded—
page.headcollectbefore </head>—
page.footercollectbefore </body>—
shop.pricefilterbefore a purchase is pricedaccount_id, offer_id, offer
shop.purchasednotifyafter the points are takenaccount_id, offer_id, type, itemid, count, points
account.registerednotifyafter an account is createdname, email
character.creatednotifyafter a character is createdname, account_id, vocation
character.renamednotifyafter an admin renames a characterplayer_id, old_name, new_name
payment.completednotifyafter a real-money payment is recordedprovider, 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.

Your own hooks

Publish one and other plugins can extend yours:

znote_hook('coupon.redeemed', array('code' => $code, 'account_id' => $id));

Need a hook that isn't there?

Adding one is two lines in the core file, and hooks with no listeners cost almost nothing. Open an issue rather than forking.


The example

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.


Checklist

  • Folder name is lowercase, unique, and matches nothing in ZnoteX.
  • plugin.json is valid JSON.
  • plugin.php prints nothing and runs no queries at load.
  • install.sql is idempotent.
  • Tables and functions are prefixed with the plugin name.
  • Every form has <?= acp_csrf_field() ?> (admin) or Token::create() (public).
  • Every value that reaches SQL goes through esc() or (int).
  • Every value that reaches the page goes through h() or htmlspecialchars().
  • It still works when it is disabled — that is, nothing else references it.
Top