The Joinery license includes a plugin and theme exception. Plugins and themes you create are yours — you may license them under any terms you choose, including commercial terms. The PolyForm Noncommercial license covers Joinery's core code, not your extensions. See the Plugin and Theme Exception in LICENSE.md for the full text.
This guide outlines the current plugin and theme architecture after implementing the hybrid plugin/theme system. The system provides clear separation of concerns between plugins (backend-only) and themes (user-facing routing and presentation), while enabling themes to seamlessly integrate with plugin functionality through a sophisticated view resolution system.
Plugins provide:
Themes handle all user-facing functionality:
The system now supports a hybrid approach where:
> For complete routing documentation (adding pages, route options, common patterns), see Routing. > This section covers how routing interacts with plugins and themes.
Routes are processed in this order:
When a view is requested, the system searches in this order:
/theme/{theme}/views/{view}.php/plugins/{plugin}/views/{view}.php/views/{view}.phpBefore diving in, a quick reference for the four common things plugins need to register. Use this to jump to the right section — each row points to the one canonical path.
| What you're adding | Where it goes | Section |
|---|---|---|
| Tables and columns | $field_specifications in a data class under data/ — applied automatically on install and sync | Table Creation |
| Admin menu entries | adminMenu key in plugin.json — created on activate, removed on deactivate/uninstall | Admin Menus |
| Default plugin settings | settings array in plugin.json — seeded on activate and sync | Plugin Settings |
| Signals your plugin emits, or reactions to existing signals | signals / signalSubscribers keys in plugin.json | Plugin Signals & Subscribers |
| A cloud-offload store profile (move rows' bytes to a bucket) | storage_profiles array in plugin.json — a StorageProfile class name | Storage Profiles |
| Other initial data (seed rows, categories, etc.) | .sql file in migrations/, numbered for order, idempotent | Migration System |
| Activate/deactivate logic | activate.php, deactivate.php at the plugin root, each defining {plugin}_activate() / _deactivate() | Plugin Lifecycle |
| Uninstall external cleanup (optional) | uninstall.php defining {plugin}_uninstall() — only for work the declarative systems can't do (external API calls, filesystem cleanup) | Uninstall Script |
plugin.json respectively.When developing plugins, the following core files are guaranteed to be available without requiring them:
// In any plugin file (admin, views, includes, etc.)
// ✅ CORRECT - Use directly without require
$settings = Globalvars::get_instance();
$theme = $settings->get_setting('theme_template');
$session = SessionControl::get_instance();
if (!$session->is_logged_in()) {
// Handle not logged in
}
// Use PathHelper for other includes
require_once(PathHelper::getIncludePath('data/users_class.php'));
// ❌ WRONG - Don't do this
require_once(__DIR__ . '/../../includes/PathHelper.php');
require_once(__DIR__ . '/../../includes/Globalvars.php');SessionControl::get_instance() is the only session entry point. The methods a plugin most
often needs:
| Method | Returns | Use |
|---|---|---|
is_logged_in() | bool | Gate member-only behaviour |
get_user_id() | int user id (0/none if not logged in) | Scope queries to the current member — essential for any member-owned plugin data |
get_permission() | int permission level | Compare against the permission ladder (0 any member, 5+ admin) |
check_permission($level) | void (redirects/401s if unauthorized) | First line of an admin page — redirects to /login if not logged in, throws 401 if under-ranked |
get_timezone($default = NULL) | IANA tz string | Pass to LibraryFunctions::convert_time() for display |
set_return() / get_return() | void / url | Remember where to send the user back after login |
get_shopping_cart() | cart object | Commerce flows |
includes/SessionControl.php for the full surface; the above is what most plugin code
touches. Note get_user_id() is the member-scoping primitive — there is no $_SESSION access
in plugin code.Plugin directory names appear directly in user-facing URLs (/{pluginname}/*, /profile/{pluginname}/*, /admin/{pluginname}/*), so choose them carefully:
events, billing, usersscrolldaddy, mailboxdns_filtering not DnsFilteringprofile, admin, login, ajax, api, assets, theme, plugins, views, uploads, utils, tests, docs, specs, migrations, data, includes, logic, admviews/profile/billing.php exists, a plugin named billing is rejectedEvery active plugin owns three URL prefixes automatically:
| URL pattern | View file | Example |
|---|---|---|
/{plugin} | plugins/{plugin}/views/index.php | /scrolldaddy |
/{plugin}/* | plugins/{plugin}/views/*.php | /scrolldaddy/pricing |
/profile/{plugin} | plugins/{plugin}/views/profile/index.php | /profile/scrolldaddy |
/profile/{plugin}/* | plugins/{plugin}/views/profile/*.php | /profile/scrolldaddy/devices |
/admin/{plugin} | plugins/{plugin}/views/admin/index.php | /admin/scrolldaddy |
/admin/{plugin}/* | plugins/{plugin}/views/admin/*.php | /admin/scrolldaddy/settings |
Index convention: When the URL has no trailing path (e.g. /profile/scrolldaddy), the router loads index.php from the corresponding views subdirectory.
Internal links must always use namespaced URLs:
// ✅ CORRECT
<a href="/profile/scrolldaddy/devices">My Devices</a>
// ❌ WRONG — only works on sites where this plugin IS the theme
<a href="/profile/devices">My Devices</a>Plugin-as-theme shortcut: When a plugin is set as the active theme (theme_template = 'plugin'), its views are found through theme resolution. Both /profile/devices (clean URL via theme) and /profile/scrolldaddy/devices (namespaced URL) resolve to the same file.
Adding permissions or model binding: Use serve.php for routes that need more than a view file — but the route pattern must be within the namespace:
// plugins/myplugin/serve.php
$routes = [
'dynamic' => [
'/profile/myplugin/settings' => [
'view' => 'views/profile/settings',
'min_permission' => 0,
],
],
];
Routes outside the namespace are dropped with a logged warning./plugins/my-plugin/
├── plugin.json # Plugin metadata
├── serve.php # Only needed for routes requiring model/permission config
├── views/
│ ├── index.php # /myplugin (landing page)
│ ├── pricing.php # /myplugin/pricing
│ ├── profile/
│ │ ├── index.php # /profile/myplugin
│ │ ├── dashboard.php # /profile/myplugin/dashboard
│ │ └── settings.php # /profile/myplugin/settings
│ └── admin/
│ ├── index.php # /admin/myplugin
│ └── config.php # /admin/myplugin/config
├── data/ # Data model classes
├── logic/ # Business logic (LogicResult pattern)
├── admin/ # Admin interface files (/adm/admin_*)
├── ajax/ # External webhooks only (page JS uses /api/v1 actions)
├── includes/ # Helper classes and libraries
├── migrations/ # Database migrations
└── uninstall.php # (optional) external-cleanup hook — most plugins don't need oneMinimum required plugin.json:
{
"name": "My Plugin Name",
"version": "1.0.0",
"description": "Plugin description"
}Complete plugin.json example:
{
"name": "My Advanced Plugin",
"description": "A comprehensive backend plugin",
"version": "2.1.0",
"author": "Your Name or Company",
"license": "MIT",
"homepage": "https://yoursite.com/plugin-docs",
"requires": {
"php": ">=8.0",
"joinery": ">=1.0",
"extensions": ["pdo", "json", "curl"],
"composer": { "vendor/package": "^2.0" }
},
"depends": {
"core-plugin": ">=1.0"
},
"host_installer": "provisioning/install_myplugin.sh",
"provides": ["api-endpoint", "widget-support"],
"tags": ["utility", "api", "backend"]
}> Informational-only keys. author, license, homepage, tags, and provides are
> documentation metadata — the system reads the manifest but does not act on these keys.
> In particular, provides does not create any dependency, capability, or routing effect;
> declaring provides: ["widget-support"] does not make another plugin able to depends on it.
> The keys the loader actually consumes are name, version, description, requires,
> depends/conflicts, settings, adminMenu, profileMenu, provisioners,
> host_installer, receives_upgrades, included_in_publish, and deprecated/superseded_by.
The version field is the source of truth for a component's released identity, and it carries through to its archive filename ({name}-{version}.tar.gz). Versioning is governed by three rules:
requires for plugins and themes. A component whose requires.joinery / requires.php / requires.extensions are not satisfied is refused activation with the specific failure reported. The gate runs only on activation, so an already-active component that newly fails requirements keeps running.depends) are evaluated against the dependency's live manifest version, read from its plugin.json on disk — not a cached copy. A constraint like "depends": {"mailbox": ">=1.10.0"} is satisfied whenever the installed mailbox manifest reports a version that meets it.A plugin that needs something installed on the host declares it; the platform installs it at the moments that have the privileges to do so. There are three tiers, matched to what each dependency kind needs:
1. PHP extensions — requires.extensions. Declare the extension name (e.g. "extensions": ["imagick"]). The install tooling reads every bundled plugin's declarations via utils/list_dependencies.php and apt-installs the union at the root moments: Docker image build, install.sh site build, and upgrade.php on nodes. Activation checks extension_loaded() and refuses with the specific missing extension when the host somehow lacks it — the same gate applies to themes via theme.json requires.extensions. Core's own extension requirements live as ext-* entries in root composer.json, not in any plugin manifest.
2. Composer libraries — requires.composer. Declare {"vendor/package": "constraint"}. This is the one tier that installs at activation: PluginManager runs a reconcile (owned by ComposerValidator::reconcilePluginPackages()) that composer requires anything missing into the single root composer.json — there is one manifest, one vendor dir, one autoloader. Composer resolves each constraint; an unsatisfiable one refuses activation with composer's output. Because the root manifest holds one constraint per package, two plugins declaring the same package with different constraints is detected before composer runs and refuses activation naming both plugins (identical constraints are fine — declare what you need even if another plugin already ships it). The reconcile also runs at update_database time, covering deploys that add a dependency to an already-active plugin. Reconciled packages are recorded in composer.json extra.joinery-plugin-packages; utils/list_dependencies.php --orphans lists recorded packages no plugin declares anymore (deactivation never removes packages). Activation-time reconcile needs network access to the composer registry.
3. Host services and system packages — host_installer. For anything with real host-level complexity (daemons, config files, apt packages beyond PHP extensions), the plugin ships its own installer script and declares its path: "host_installer": "provisioning/install_myplugin.sh". The path must stay inside the plugin directory. The script's contract:
apt-get install and edit /etc; it should verify root and refuse otherwise.DEBIAN_FRONTEND=noninteractive; never prompt.maintenance_scripts/install_tools/_plugin_installers_start.sh executes every active plugin's declared installer at the root moments without systemd: the container CMD (every start), install.sh site builds, and upgrade.php. The runner is fail-safe — an installer failure logs a warning and never blocks container start. The Mailbox plugin's provisioning/install_email.sh is the reference implementation.Activation cannot run a host_installer (web requests lack root) — pair the installer with provisioners entries (below) so the admin UI detects missing host state and points at the fix; on Docker nodes a container restart runs the installer automatically.
Plugins (and themes) support two optional deprecation fields:
| Field | Type | Default | Description |
|---|---|---|---|
deprecated | bool | false | Marks the extension as deprecated |
superseded_by | string | null | Directory name of the replacement extension |
{
"name": "Old Plugin",
"version": "1.0.0",
"receives_upgrades": true,
"included_in_publish": true,
"deprecated": true,
"superseded_by": "new-plugin"
}Effect of deprecated: true:
Plugins provide data models using the SystemBase pattern. For a complete annotated reference model
covering every property — schema, REST API exposure/authorization, AI surface, deletion, and the
Multi collection class — see docs/example_class.php.
// plugins/my-plugin/data/my_data_class.php
class MyData extends SystemBase {
public static $prefix = 'mdt'; // REQUIRED — see below
public static $tablename = 'mdt_my_data';
public static $pkey_column = 'mdt_id';
public static $field_specifications = array(
'mdt_id' => array('type' => 'int8', 'is_nullable' => false, 'serial' => true),
'mdt_usr_user_id' => array('type' => 'int8', 'is_nullable' => true),
'mdt_name' => array('type' => 'varchar(255)', 'is_nullable' => false, 'required' => true),
'mdt_description' => array('type' => 'text', 'is_nullable' => true),
'mdt_created' => array('type' => 'timestamp(6)', 'is_nullable' => false, 'default' => 'now()'),
);
// REQUIRED on every model (may be empty): cleanup actions for permanent_delete().
public static $permanent_delete_actions = array();
// Define foreign key behavior (optional - defaults to cascade)
protected static $foreign_key_actions = array(
'mdt_usr_user_id' => array('action' => 'set_value', 'value' => User::USER_DELETED)
);
}Schema dialect — get this right. Column types are SQL type strings: int8 (with
'serial' => true for an auto-increment primary key), varchar(255), text, timestamp(6),
numeric(10,2). Use 'is_nullable' => false (a schema constraint) to make a column
NOT NULL; 'required' => true is a separate validation key checked on save. There is no
'length' key and no bare 'int' / 'varchar' — those produce no usable column. (The same
correct dialect appears under Table Creation.)
The $prefix property is required. Every column in the table is named {$prefix}_{field},
and the constructor throws SystemBaseException('This object has no prefix.') if it is unset
(includes/SystemBase.php:59). It is also the key FormWriter and the REST API use to map a
field back to its model. $tablename and $pkey_column are likewise required; a Multi
collection class additionally needs $model_class (see Writing a Multi (collection) class below).
REST API exposure & per-record scope. A core model is a CRUD resource only if it opts in with
public static $api_readable = true; and/or $api_writable = true; (both default false); plugin
models are never exposed to CRUD — expose plugin behaviour through action endpoints instead. For an
exposed model, the default row scope is owner-or-staff (deny): a caller may touch a row only if
they own it (the {prefix}_usr_user_id column matches the acting user) or they are staff
(permission ≥ 5). Set public static $api_public_read = true; to make a resource world-readable
(catalog content), or override authenticate_read($data) / authenticate_write($data) for a custom
rule — copy the pattern from data/orders_class.php. See
REST API → Per-record authorization.
Deletion Behavior: For complete documentation on defining foreign key actions, cascading deletes, soft-delete cascading patterns, and undelete strategies, see the Deletion System Documentation.
Every single-row model has a companion Multi class (collection) in the same file, extending
SystemMultiBase. It declares $model_class (the single-row class name) and implements
getMultiResults(), which translates caller-supplied option keys into SQL filters:
class MultiMyData extends SystemMultiBase {
public static $model_class = 'MyData'; // REQUIRED — the single-row class
protected function getMultiResults($only_count = false, $debug = false) {
$filters = array();
// 1. Parameterized value (safe binding) — produces mdt_usr_user_id = ?
if (isset($this->options['user_id'])) {
$filters['mdt_usr_user_id'] = array($this->options['user_id'], PDO::PARAM_INT);
}
// 2. Literal condition appended as-is — produces mdt_delete_time IS NULL
if (isset($this->options['active'])) {
$filters['mdt_delete_time'] = 'IS NULL';
}
// 3. ILIKE / raw fragment — produces mdt_name ILIKE '%term%'
if (isset($this->options['name_like'])) {
$filters['mdt_name'] = "ILIKE '%" . $this->options['name_like'] . "%'";
}
return $this->_get_resultsv2(static::$model_class::$tablename, $filters, $this->order_by, $only_count, $debug);
}
}Construct and use it:
$rows = new MultiMyData(
array('user_id' => $uid, 'active' => true), // $options → filter keys above
array('mdt_created' => 'DESC'), // $order_by
20, 0 // $limit, $offset
);
$total = $rows->count_all(); // count ignoring limit/offset
$rows->load(); // populate
foreach ($rows as $row) { /* $row is a MyData */ }The constructor signature is
__construct($options = array(), $order_by = array(), $limit = NULL, $offset = NULL, $operation = 'AND', $write_lock = FALSE).
The option keys are whatever getMultiResults() reads (here user_id, active, name_like)
— they are deliberately not the raw column names. Always read a Multi class's
getMultiResults() to learn its accepted keys.
AI Auto-Discovery: To make a plugin model queryable by joinery_ai recipes, declare the three $ai_* static properties ($ai_readable, $ai_description, $ai_excluded_fields) on the class. Default-deny: omit them and the model stays invisible to AI tools. See the Joinery AI Plugin Documentation for the property contract and the auto-block regex.
Plugin logic files follow the same LogicResult pattern as core logic files. Every logic file in the codebase — core or plugin — uses one signature: function foo_logic(array $input): LogicResult. There is no second variant. For comprehensive documentation, see the Logic Architecture Guide.
// plugins/my-plugin/logic/my_feature_logic.php
<?php
function my_feature_logic(array $input): LogicResult {
require_once(PathHelper::getIncludePath('includes/LogicResult.php'));
require_once(PathHelper::getIncludePath('plugins/my-plugin/data/my_data_class.php'));
// Business logic processing
$data = new MyData($input['id'], TRUE);
// Use LogicResult for consistent returns
if (($input['action'] ?? null) === 'delete') {
$data->soft_delete();
return LogicResult::redirect('/plugins/my-plugin/admin/list');
}
return LogicResult::render(['data' => $data]);
}
?>Key points for plugin logic files:
LogicResult::render(), LogicResult::redirect(), or LogicResult::error()[feature]_logic.php with matching function name__DIR__A plugin logic function becomes a REST API action by adding the same _logic_descriptor() companion core logic files use:
// plugins/my-plugin/logic/my_feature_logic.php
function my_feature_logic_descriptor(): array {
return [
'description' => 'What this action does',
'requires_session' => true, // default: true
'mutates' => true,
'input' => [
'device_id' => ['type' => 'int', 'required' => true],
],
];
}The input schema drives boundary validation and appears in discovery — see docs/api.md for the field types and the legacy _logic_api() companion.
The action is addressed under the plugin's namespace — POST /api/v1/action/my-plugin/my_feature — and listed in GET /api/v1/actions as my-plugin/my_feature. Resolution goes directly to plugins/{plugin}/logic/{action}_logic.php; only active plugins resolve, and the namespace means a plugin action can never collide with a core action or another plugin's.
requires_session => true actions run under session simulation as the API key's user, so SessionControl works exactly as it does on the web. Use $session->is_api_context() when a function needs to return a JSON-clean payload instead of view-shaped objects.apk_permission >= 2). To override — e.g. a read-only action, or one requiring a higher user role — add an 'auth' block to the descriptor: 'auth' => ['capability' => 'read', 'min_user_permission' => 5]. The contract is enforced by ApiAuth::authorize(); see docs/api.md.{action}_logic_form() companion exposes a server-driven form definition at GET /api/v1/form/{plugin}/{action} — see docs/api.md and docs/formwriter.md.$input parameter (merged GET + JSON body). Do not read $_REQUEST — it never sees the JSON body.Plugin admin pages are accessed via the plugin admin discovery route:
/plugins/{plugin}/admin/{page}
// plugins/my-plugin/admin/admin_my_plugin.php
<?php
// Core files are already available - no need to require them
// PathHelper, Globalvars, and SessionControl are pre-loaded
// Use PathHelper for other includes
require_once(PathHelper::getIncludePath('includes/AdminPage.php'));
$session = SessionControl::get_instance();
$session->check_permission(5);
$page = new AdminPage();
$page->admin_header([
'title' => 'My Plugin',
'menu-id' => 'my-plugin',
'readable_title' => 'My Plugin Management'
]);
// Admin interface content here
$page->admin_footer();
?>Two admin surfaces — which to use. A plugin can render admin pages two ways, and both resolve automatically:
| Surface | File | Gives you |
|---|---|---|
| AdminPage route (recommended) | plugins/{plugin}/admin/{page}.php → /plugins/{plugin}/admin/{page} | The full admin shell: an AdminPage object, admin_header()/admin_footer() chrome, $page->getFormWriter(), sidebar highlight. Mirrors core /adm/ + /adm/logic/. |
| View auto-discovery | plugins/{plugin}/views/admin/{page}.php → /admin/{plugin}/{page} | A plain view in the plugin's URL namespace, no AdminPage chrome. |
plugins/{plugin}/admin/) for real admin tooling — list tables,
edit forms, anything that should look and behave like the rest of the admin. Pair it with a
plugins/{plugin}/admin/logic/{page}_logic.php logic file, exactly as core admin pages pair
/adm/ with /adm/logic/. Reach for views/admin/* only for a simple custom panel that
genuinely doesn't want the admin chrome.A plugin's own assets live in plugins/{plugin}/assets/ and are served by the built-in static
route /plugins/{plugin}/assets/* (the route enforces that the plugin is active and refuses to
serve .php). There is no PluginHelper::asset() helper — cache-busting is done inline with
filemtime(), the same mechanism themes use. Define a tiny closure in your view and reference
assets through it:
$asset = function ($rel) {
$path = PathHelper::getIncludePath('plugins/my-plugin/assets/' . $rel);
return '/plugins/my-plugin/assets/' . $rel . '?v=' . (is_file($path) ? filemtime($path) : '1');
};
echo '<link rel="stylesheet" href="' . htmlspecialchars($asset('css/my-plugin.css')) . '">';
echo '<script defer src="' . htmlspecialchars($asset('js/my-plugin.js')) . '"></script>';The ?v={filemtime} query changes whenever the file changes, so browsers refetch on every edit
and otherwise cache. Always reference your assets by their namespaced /plugins/{plugin}/assets/…
URL — never a relative path.
The inline closure above is for assets that belong to one page (a page-specific reader script,
say). For a plugin's stylesheet — the sheet that styles its UI wherever it appears — declare it
instead (next section); don't echo a <link> per page.
Declare a plugin's stylesheets under the styles key in plugin.json — an array of paths relative
to the plugin root. Every declared sheet loads on every page while the plugin is active,
cache-busted by file mtime, emitted *after the .jy-ui kit and before the active theme's
stylesheet. That position is deliberate: your rules resolve the kit's --jy-* design tokens, and
a theme can still override them.
{
"name": "My Plugin",
"styles": ["assets/css/my-plugin.css"]
}Because the sheet is global, scope every rule so it stays inert until your markup opts in — wrap
your UI in .jy-ui and prefix classes .jy-{plugin}-* (e.g. .jy-ui .jy-myplugin-card { … }), or
use a distinctive component prefix. No call site is needed; PluginHelper::renderActivePluginStyleLinks()
emits the <link> tags from the head. Changes to styles take effect immediately (the manifest is
read live from disk) — no activation or update_database step.
Plugins declare menu contributions in plugin.json under two keys:
adminMenu — items in the admin sidebar (/admin/*).profileMenu — items in the member profile menu (logged-in avatar menu, logged-out auth links, etc.).amu_admin_menus table, distinguished by an amu_location column (admin_sidebar vs user_dropdown). The system automatically creates menu rows on activation, updates them on sync, and removes them on deactivation/uninstall. This is the only supported way to register plugin menus — do not INSERT into amu_admin_menus from migrations.A profileMenu entry is the single way onto the member menu, everywhere: every theme renders the seeded store via PublicPageBase::get_menu_data() ($menu_data['user_menu']['items'], each item {label, link, icon, slug}), and the mobile apps' navigation endpoint reads the same store. Themes style the markup but never hardcode member menu entries — adding one entry in a manifest surfaces it on all web themes and in every shipped app with no theme edits or app release.
Locations:
| Location | Source key | Permission floor | Visibility |
|---|---|---|---|
admin_sidebar | adminMenu | ≥ 1 | always in (logged in) |
user_dropdown | profileMenu | ≥ 0 | in / out / both |
PluginManager::syncMenus()): each item must supply a
slug, title, and order; the slug must be a non-empty string; and the slug must
not start with core- (that prefix is reserved for core menu rows seeded by
migrations). These are the only rules the live sync checks.mybooks-shelf), keep it to [a-z0-9-], ≤ 32 chars, and unique within the plugin.
This is what keeps your slugs from colliding with core or other plugins — but it is a
convention, not an enforced requirement. A bare slug like shelf will sync fine, and a
hyphenated plugin name is the right prefix for an underscore-named plugin directory
(e.g. dns_filtering → dns-filtering-…).adminMenuThree placement patterns:
1. Parent group with children -- creates a top-level menu section:
{
"adminMenu": [
{
"slug": "my-plugin",
"title": "My Plugin",
"icon": "plug",
"permission": 8,
"order": 15,
"items": [
{ "slug": "my-plugin-dashboard", "title": "Dashboard", "url": "/admin/my_plugin", "order": 1 },
{ "slug": "my-plugin-settings", "title": "Settings", "url": "/admin/my_plugin/settings", "order": 2 }
]
}
]
}Children inherit the parent's permission unless they override it.
2. Child attachment -- attaches to any existing menu by slug:
{
"adminMenu": [
{
"slug": "mailbox-reader",
"title": "Mailbox",
"url": "/plugins/mailbox/admin/admin_mailbox_reader",
"parent": "emails",
"permission": 5,
"order": 10,
"settingActivate": "mailbox_enabled"
}
]
}The parent value is the amu_slug of any menu in the system -- core menus, other plugin menus, or groups from the same plugin.
3. Standalone top-level -- a single entry with no children or parent:
{
"adminMenu": [
{ "slug": "my-tool", "title": "My Tool", "url": "/admin/my_tool", "icon": "wrench", "permission": 10, "order": 16 }
]
}Available fields:
| Field | Required | Default | Description |
|---|---|---|---|
slug | Yes | -- | Unique identifier ([a-z0-9-], max 32 chars) |
title | Yes | -- | Display text (max 32 chars) |
order | Yes | -- | Sort position within parent level |
url | No | "" | Target page. URLs starting with / are stored as-is |
icon | No | null | Icon identifier |
permission | No | 10 | Min permission level (1-10) |
settingActivate | No | null | Setting that must be truthy for menu to display |
disabled | No | false | Whether disabled by default |
parent | No | null | Slug of parent menu to attach under |
items | No | null | Array of child menu items |
plugin.json are the source of truth. Manual edits via the admin menu UI will be overwritten on the next sync.profileMenuProfile menu items appear in the user dropdown. They are flat — no parent/items nesting — and support a per-row visibility value that selects between logged-in, logged-out, and both states.
{
"profileMenu": [
{
"slug": "scrolldaddy-filtering",
"title": "Filtering",
"url": "/profile/scrolldaddy",
"icon": "shield",
"visibility": "in",
"permission": 1,
"order": 75
}
]
}Available fields:
| Field | Required | Default | Description |
|---|---|---|---|
slug | Yes | -- | Unique identifier; conventionally prefixed with the hyphenated plugin name (e.g. myplugin-settings) to avoid collisions. Sync enforces only that it is a non-empty string and does not start with core- (see the Slug rules above). |
title | Yes | -- | Display text (max 32 chars). |
url | Yes | -- | Target page (no .php). Stored as-is. |
order | Yes | -- | Sort position in the dropdown. Core slots: home=10, profile=50, signout=200. |
icon | No | null | Icon identifier passed through to theme renderers. |
visibility | No | "in" | One of "in" (logged-in), "out" (logged-out), "both". |
permission | No | 0 | Min permission level (0-10). Only applies when logged in. |
settingActivate | No | null | Setting that must be truthy for the row to display. |
disabled | No | false | Whether disabled by default. |
parent and items are not supported on profileMenu — the user dropdown is rendered as a flat list. Themes that need additional grouping handle it at the render layer.Themes consuming the dropdown: themes read $menu_data['user_menu']['items'] returned by PublicPageBase::get_menu_data(). Each item carries label, link, icon, and slug. Filter by slug (e.g. str_starts_with($item['slug'], 'core-admin-')) — never by label, since admins can rename labels in the admin UI.
> ⚠️ Settings are a two-step setup. Declaring in plugin.json only seeds the row in stg_settings — it does not make the setting appear in the admin UI. To expose a setting on /admin/admin_settings, you must also create a settings_form.php file in your plugin directory (see Plugin Settings Form below). Setting names in the two files must match exactly.
Plugin default settings are declared in plugin.json under an optional settings key. On activate and on every sync, PluginManager seeds any declared row that doesn't already exist in stg_settings. Existing values are never overwritten.
{
"name": "My Plugin",
"version": "1.0.0",
"settings": [
{ "name": "myplugin_enabled", "default": "1" },
{ "name": "myplugin_max_items", "default": "50" },
{ "name": "myplugin_api_key", "default": "" }
]
}Fields:
| Field | Required | Default | Description |
|---|---|---|---|
name | Yes | — | Setting key. Must start with the plugin's directory name (unless legacy_core). |
default | No | "" | String value stored in stg_value. Always a string — use "0"/"1" for booleans, "42" for numbers. JSON-native booleans/numbers are rejected at validation time. |
legacy_core | No | false | Opts this one setting out of the prefix rule so it keeps an unprefixed core-era name. See below. |
name must start with the plugin's directory name (e.g., a plugin at /plugins/bookings/ must declare settings named bookings_*) — unless the entry sets "legacy_core": true.name may collide with a core setting in settings.json at the public_html/ root.legacy_core — carrying a core setting into a plugin. When a feature moves from core into a plugin, its settings keep their original unprefixed names on purpose — menu settingActivate keys, existing stg_settings rows, and code all reference the old name. Mark each such entry "legacy_core": true to opt out of the prefix rule (the store and event_manager plugins do this for products_active, events_active, etc.). The collision and string-default rules still apply: the name must have been removed from core settings.json when it moved. New settings never use this — it exists only for extractions.Validation failures throw. On activate() the plugin does not activate; on sync() the offending plugin is skipped with a logged error and other plugins continue.
Seed-only policy: Existing setting values are never overwritten. If your plugin's v2 changes a declared default, existing sites keep their old value and only new installs get the new default. If you need existing sites to pick up a new default, write an SQL migration — silent default changes across upgrades have bitten production systems badly enough that the operator needs to opt in.
Orphan rows: Settings dropped from the manifest in a later version are not automatically deleted. Use an SQL migration if you need the row gone. Orphan setting rows are otherwise harmless — nothing reads them.
Blank defaults: default: "" creates a row with an empty value. Use this for things that have no meaningful factory default but should still be present (API keys, SMTP hosts, custom CSS) so the row exists for settings_form.php to render and for admins to fill in. Omitting the declaration entirely means no row in stg_settings, even if settings_form.php references the name — the form-page save logic auto-creates missing rows on first submit, but until then get_setting() returns '' (an empty string, and logs a notice — pass the $fail_silently flag to suppress it) and the field renders empty.
Uninstall: On uninstall, PluginManager deletes rows matching the names in the current manifest. Settings declared in an earlier version but dropped from the current manifest are left in place.
A plugin contributes a cloud-offload store profile — code that moves a table's rows' bytes to a bucket and pulls them back — by declaring a StorageProfile class name in plugin.json under an optional storage_profiles key. This sits next to settings and the menu keys as a plugin extension point. See Cloud Storage for the full architecture (driver / engine + lifecycle / visibility / profile).
"storage_profiles": ["MyPluginRawStore"]plugins/<plugin>/includes/<ClassName>.php, implements the StorageProfile interface (includes/cloud_storage/StorageProfile.php), and has a no-argument constructor.visibility() returns 'public' or 'private'; that single value decides which store the rows go to, how bytes are read back, and the privacy guarantee. The manifest never restates it.StorageProfileRegistry scans every plugin's plugin.json regardless of activation state, so the binding-immutability guard can always see a deactivated plugin's offloaded (cloud) rows — deactivation leaves the files (and so the declaration and class) in place.private profile must therefore have its store drained back to local first (the Disable-and-Pull flow) before uninstall — otherwise its cloud rows become invisible to the guard and a later bucket change could strand them. Deactivation alone is safe; only uninstall requires the drain.Plugins integrate with the signal bus through two optional plugin.json keys. Both are merged with core for active plugins only, cached per request.
signals — signals your plugin emits. Same shape as core signals.json: identity (label, description, category), a payload schema, and an optional notify block to make the signal produce notifications. Declaring a signal lets other plugins and core subscribers consume it; dispatch it from your plugin code with SignalBus::dispatch().
"signals": {
"myplugin.thing_happened": {
"label": "Thing happened",
"description": "A thing happened in My Plugin.",
"category": "My Plugin",
"payload": { "thing_id": "Thing id", "source_user_id": "Acting user id" },
"notify": {
"ntf_type": "system",
"supports_topic": true,
"default_email": false,
"title_template": "A thing happened (#{thing_id})",
"body_template": "Thing {thing_id} happened.",
"link_template": "/admin/myplugin/things"
}
}
}To make a plugin signal notifiable, add the notify block inline as above — do not register a separate subscriber for it; the core Notify subscriber handles any signal that carries a notify block.
signalSubscribers — reactions to signals (yours or core's). Same shape as core signal_subscribers.json, with file relative to your plugin directory:
"signalSubscribers": {
"myplugin_provisioner": {
"file": "includes/MyProvisioner.php",
"class": "MyProvisioner",
"method": "handle_signal",
"signals": ["subscription.expired"]
}
}The handler is a static method (string $signal, array $payload): void. Keep inline work cheap (a local insert) and push slow work to a scheduled task — see the handler cost budget. The file is required lazily, only when one of its signals fires.
PluginManager is the single entry point for all lifecycle operations. Plugin models (Plugin, PluginHelper) are pure CRUD — never call lifecycle methods directly on them.
Three states: active, inactive, and uninstalled (no row at all).
Discovery → Install → Activate ↔ Deactivate → Uninstall
↑ │
└────────────── Install ─────────────┘Install (PluginManager::install($name))
plugins/{name}/, so plugins with included_in_publish: true on the upgrade server get current code on every install; plugins not in the publisher's catalog 404 silently and install proceeds with on-disk files.$field_specifications (via DatabaseUpdater::runPluginTablesOnly()).sql migration files in plugins/{name}/migrations/plg_plugins with status inactivePluginManager::activate($name))
DatabaseUpdater::runPluginTablesOnly() — picks up any $field_specifications changes since installactivate.php hook (calls {plugin_name}_activate() if defined)plg_active = 1$field_specifications on an already-installed plugin, then run Sync with Filesystem from the admin Plugins page (/admin/admin_plugins?action=sync_filesystem). Sync applies the full column reconciliation: new tables, new columns, type/length and nullability modifications (widening a varchar, adding NOT NULL) on existing columns, unique constraints, and indexes. Column removal is the one schema change sync never performs — dropping a column absent from the spec stays a deliberate migration-or-manual act. Schema changes are also applied automatically during deploys (upgrade.php) and when running update_database from admin utilities.Schema changes on inactive plugins are deferred. Sync and update_database only touch tables for active plugins. If you modify $field_specifications on a plugin that is installed but not active, the schema change will not be applied until the plugin is next activated (PluginManager::activate() calls runPluginTablesOnly() as its first step).
Sync (PluginManager::sync())
DatabaseUpdater::runPluginTablesOnly() — creates missing tables, adds missing columnsDatabaseUpdater::processAdvancedColumnOperations(), then unique constraints and indexesPluginHelper::registerAllActiveDeletionRules()Deactivate (PluginManager::deactivate($name))
deactivate.php hooksct_is_active = false) — tasks resume on reactivationplg_active = 0PluginManager::uninstall($name)) — destructive, cannot be undone. Plugin files stay on disk; everything else is removed.plugin.json). Settings dropped from a later manifest version are left as orphans.uninstall.php hook if present. Tables are still available here for external teardown (e.g., revoking cached external state).plg_plugins rowAfter uninstall, the plugin appears in the admin UI as "Inactive" with an Install action (no DB row, files still on disk). Reinstall goes through the normal install path — on install the upgrade-endpoint refresh pulls fresh published code, so stale on-disk files don't linger.
Important: The core update_database.php script excludes plugins from its main pipeline (include_plugins => false) because plugin tables have independent lifecycles. However, update_database runs a plugin/theme sync as its final step, so plugin schema changes are still applied when you run it.
Plugin tables are created automatically from data class $field_specifications — you do NOT write CREATE TABLE statements. Simply define your data model classes in plugins/{name}/data/ and tables will be created when the plugin is installed.
// plugins/my-plugin/data/my_data_class.php
class MyData extends SystemBase {
public static $prefix = 'mdt';
public static $tablename = 'mdt_my_data';
public static $pkey_column = 'mdt_my_data_id';
public static $field_specifications = array(
'mdt_my_data_id' => array('type'=>'int8', 'is_nullable'=>false, 'serial'=>true),
'mdt_name' => array('type'=>'varchar(255)', 'required'=>true),
'mdt_create_time' => array('type'=>'timestamp(6)', 'default'=>'now()'),
'mdt_delete_time' => array('type'=>'timestamp(6)'),
);
}Indexes are declared the same way as for core tables and applied on install and on Sync with Filesystem — inline with 'index' => true / 'index_with' => array(...) for plain btree, or a table-level $index_specifications array for partial, expression, method-override, and scoped-unique indexes. See Index Management for the full surface.
Choosing a prefix: Your plugin's table prefix (e.g. abc in abc_items) must be unique across all plugins installed on a site. Use a short abbreviation of your plugin name — at least 3 characters. The system will block installation if your class names or table names collide with an installed plugin, and will warn if your prefix matches even when table names don't.
For default plugin settings, use the settings key in plugin.json (see Plugin Settings above). Migrations are for initial data seeds only — dropdown options, category rows, reference data — that doesn't fit the settings model. Schema is handled automatically from $field_specifications (see Table Creation above), and admin menus are declared in plugin.json (see Admin Menus above) — none of those belong in a migration.
Migrations are .sql files placed in plugins/{name}/migrations/:
-- plugins/my-plugin/migrations/001_seed_categories.sql
INSERT INTO mpc_my_plugin_categories (mpc_name)
SELECT 'Default Category'
WHERE NOT EXISTS (SELECT 1 FROM mpc_my_plugin_categories WHERE mpc_name = 'Default Category');Rules:
001_seed_categories.sql, 002_seed_defaults.sql).plm_plugin_migrations; each file runs exactly once per site.WHERE NOT EXISTS, ON CONFLICT DO NOTHING) so a file that partially applied can be safely re-run after the tracking row is cleared.Settings declared in plugin.json's settings array (see Plugin Settings above) are seeded into the database on plugin activate. The settings_form.php file renders them in the admin settings page. The names used in both must match exactly — the manifest handles seeding, the form file handles UI.
If your plugin has configurable settings, create a settings_form.php file in your plugin directory. The admin settings page (/adm/admin_settings) automatically discovers and includes this file — no registration required.
plugins/my-plugin/settings_form.phpThe file is included inside an already-open FormWriter form, so you output fields directly using $formwriter. The variables $formwriter, $settings, and $session are all available in scope.
<?php
// plugins/my-plugin/settings_form.php
// $formwriter, $settings, and $session are already available
echo '<p>Configure My Plugin settings below.</p>';
$formwriter->textinput('my_plugin_api_url', 'API URL', [
'value' => $settings->get_setting('my_plugin_api_url'),
'placeholder' => 'e.g. https://api.example.com',
]);
$formwriter->passwordinput('my_plugin_api_key', 'API Key', [
'value' => $settings->get_setting('my_plugin_api_key'),
'placeholder' => 'Your API key',
]);
$formwriter->checkboxinput('my_plugin_enabled', 'Enable My Plugin', [
'value' => $settings->get_setting('my_plugin_enabled'),
]);Rules:
my_plugin_) to avoid collisions with core settings or other plugins.$settings->get_setting('name') to read current values — this handles missing rows gracefully.passwordinput for secrets (API keys, tokens) so the value is masked in the browser.plugin.json's settings array so it exists on fresh installs (see Plugin Settings above).uninstall.php is optional. Most plugins don't need one — the system automatically drops tables, deletes declared settings and menus, removes scheduled task / version / dependency / migration records, and deletes the plg_plugins row.
Create uninstall.php only when you need external cleanup the system can't do:
{plugin_name}_uninstall() — must match the plugin directory name.true on success. Return false or throw to signal failure.plg_plugins row are preserved, leaving the plugin in a recoverable state. Fix the hook and re-run uninstall — the scaffolding cleanup steps are idempotent.// plugins/my-plugin/uninstall.php
function my_plugin_uninstall() {
try {
// Example: revoke an API key with an external service.
// Tables are still available here if you need to read credentials
// or enumerate records that reference external resources.
$api_key = Globalvars::get_instance()->get_setting('my_plugin_api_key');
if ($api_key) {
external_api_revoke_key($api_key);
}
return true;
} catch (Exception $e) {
error_log("My Plugin uninstall failed: " . $e->getMessage());
return false; // preserves tables + row so operator can fix and retry
}
}Do not include DROP TABLE, DELETE FROM stg_settings, or DELETE FROM amu_admin_menus in the hook — those are the system's job now. A hook that duplicates them isn't harmful (drops are IF EXISTS, deletes match exact keys the system already cleared), but the extra code rots.
update_database handles the
A plugin can be installed and activated while one of these resources is missing or misconfigured, so the feature silently fails. Provisioning checks detect that on demand and surface it on the admin Plugins page (/admin/admin_plugins), with the command that fixes it where one exists. The provisioning check itself only detects and reports — it never runs a fix. Installation is the job of the plugin's declared host_installer (see Declaring Dependencies), which the platform runs automatically at container start, site build, and upgrade; a provisioner's script field typically points at that same installer so the admin UI names the fix.
Declare runtime dependencies as a provisioners array in plugin.json, alongside settings and adminMenu:
"provisioners": [
{
"key": "inbound_mail_server",
"label": "Inbound mail server (Postfix) running",
"details": "Postfix on the host receives inbound mail and pipes it to the forwarder.",
"check": { "type": "probe", "probe": "tcp", "host": "host-gateway", "port": 25 },
"script": "provisioning/install_email.sh"
},
{
"key": "outbound_forwarding_relay",
"label": "Outbound mail relay for forwarding",
"details": "Forwarded messages are relayed out through this SMTP server.",
"check": { "type": "code", "call": "InboundEmailHealth::checkForwardingRelay" }
}
]| Field | Required | Purpose |
|---|---|---|
key | yes | Stable identifier, unique within the plugin. |
label | yes | Human-readable name shown in the admin UI. |
details | no | One-line explanation shown under the label. |
check | yes | A check object; type is code or probe. |
script | no | Path to a fix script, relative to the plugin root. Include it only when the fix is a host-level install; omit it when the failure is a configuration problem the admin fixes directly. |
code check — { "type": "code", "call": "Class::method" }. Use this for a resource your plugin
code check passing yields the verified state — the strongest pass.probe check — { "type": "probe", "probe": "tcp", "host": "...", "port": N }. Use this for a dependency that
code check is structurally blind to it). The system opens a TCP connection within a 5-second enforced timeout. A probe passing yields the weaker reachable state — it proves something is listening, not that it is the right software or correctly configured.probe is tcp in v1. host may be a literal IP/hostname or the token host-gateway, which resolves to the Docker bridge gateway inside a container and to 127.0.0.1 on bare metal — the portable way to say "reach a service on my host." Container-vs-bare-metal is decided by the deployment_environment flag recorded in Globalvars_site.php at install time (a reliable stored value, not a runtime heuristic). Prefer a literal 127.0.0.1 over host-gateway when the dependency is co-located with the app — same container or same host (as Email Forwarding's Postfix is); host-gateway is only needed to reach out of a container to a service running on the host itself.
code check contractcall names a static method (Class::method). By convention the class is a *Health class in the plugin's includes/ directory — the system loads plugins/{plugin}/includes/{Class}.php automatically if the class is not already loaded. The method must:
verified.ProvisioningCheckFailed → unmet. This is the PDOException, an SMTP error) and rethrow it as ProvisioningCheckFailed with a clean, human-readable message — that message is shown to the admin.Throwable, or the class/method cannot be loaded → error (a broken check, reported distinctly from a missing dependency).code check must set its own short connection timeout. The provisioning system runs the check inside a request and cannot forcibly interrupt blocked PHP I/O. A check method that connects to a dead host without a timeout will hang its own badge indefinitely. Setting a short timeout (e.g. $mailer->Timeout = 5) is a convention, not something the system enforces — it is the only thing protecting against a stuck check.Checks run asynchronously (via the plugin_provisioning_check API action) after the Plugins page renders, so a slow check never blocks the page. Each plugin with provisioners gets a rolled-up badge:
| Rollup | Badge |
|---|---|
All verified | green Setup complete |
All pass, some only reachable | teal Reachable — not fully verified |
Any unmet | amber Needs setup |
Any error | red Check failed |
unmet provisioners that declare a script — the fix command as an absolute path.The CLI equivalent is php utils/check_provisioning.php, which prints the same results and exits non-zero when anything is unmet or error.
The system has two pluggable provider abstractions for external services. Each follows the same shape: an interface, a service manager that auto-discovers concrete classes, and one provider class per third-party service. Adding a new provider is a single-file change — drop a class into the providers directory and the rest of the system picks it up.
EmailServiceProvider)includes/EmailServiceProvider.phpEmailSender (includes/EmailSender.php) — EmailSender::getAvailableServices(), EmailSender::validateService()includes/email_providers/*Provider.php (Mailgun, SMTP, …)MailingListProvider)includes/mailing_list_providers/MailingListProvider.phpincludes/mailing_list_providers/AbstractMailingListProvider.php — concrete providers extend this rather than implementing the interface directlyincludes/mailing_list_providers/MailingListProviderException.php — isRetryable() distinguishes transient (rate limit, 5xx, network) from permanent (list missing, credentials revoked) failuresMailingListService (includes/MailingListService.php) — MailingListService::getProvider(), getAvailableServices(), getProviderSettings($key)includes/mailing_list_providers/*Provider.php (MailChimp, …)| Method | Purpose |
|---|---|
getKey() / getLabel() | Identity for the mailing_list_provider setting and admin dropdown |
getSettingsFields() | Setting field definitions rendered dynamically by the admin UI |
validateConfiguration() | Cheap, no-network check that required settings are non-empty |
validateApiConnection() | Live API ping for the admin "Connection OK?" panel |
subscribe() / unsubscribe() | Idempotent operations on a remote list. Email is normalized to lowercase; throw MailingListProviderException on provider-side failures, \InvalidArgumentException on bad input |
getSubscribers() | Opaque-cursor pagination — caller passes null first, then echoes back next_cursor until it is null. Returns the canonical four-value status enum (subscribed, unsubscribed, bounced, pending) |
getLists()) live on AbstractMailingListProvider with a default body that throws \BadMethodCallException. Providers override them when their API supports the operation; consumers wrap calls in try/catch \BadMethodCallException. Future non-universal additions (sequences, broadcasts, list stats) follow the same pattern, keeping additions non-breaking for existing provider classes.Adding a new provider:
includes/mailing_list_providers/MyServiceProvider.php:
require_once(PathHelper::getComposerAutoloadPath());
require_once(PathHelper::getIncludePath('includes/mailing_list_providers/AbstractMailingListProvider.php'));
class MyServiceProvider extends AbstractMailingListProvider {
public static function getKey(): string { return 'myservice'; }
public static function getLabel(): string { return 'My Service'; }
// … implement the remaining required methods
}settings.json (factory defaults seed automatically)./admin/admin_settings_email → Mailing List Provider section) — the dropdown auto-populates from your new class.MailingList::sync_subscribe() / sync_unsubscribe()) and the sync utility (utils/mailing_list_synchronize.php) call the configured provider through MailingListService::getProvider().Canonical subscriber status enum. getSubscribers() returns one of four status values regardless of provider. Each provider class maps its native vocabulary into this set:
| Canonical | Meaning | MailChimp | ConvertKit | Listmonk |
|---|---|---|---|---|
subscribed | Actively receives mail | subscribed | active | enabled |
unsubscribed | Opted out (incl. spam-complained) | unsubscribed | cancelled, inactive, complained | disabled |
bounced | Email invalid; provider stopped sending | cleaned | bounced | blocklisted |
pending | Double opt-in not yet confirmed | pending | (n/a) | (n/a) |
complained (spam-marked) collapses into unsubscribed — for the platform's purposes the action taken on the local row is the same. Mapping is typically ~5 lines of switch per provider.Out of scope (deliberate deferrals). Three categories of methods are intentionally NOT on the interface today; they will be added when a concrete second provider needs them:
registerWebhook, verifyWebhookSignature) are not part of the contract. When added they go on the required interface — every modern provider supports them.getSettingsFields() shape can't express an OAuth flow. When a provider needing OAuth is added, that provider class implements an additional method (e.g. getOAuthAuthorizationUrl()) outside the formal interface; the admin UI checks for its presence via method_exists.createList() is not on the interface. Current workflow: admins create lists in the provider's UI and enter the ID locally. Add programmatically only when a concrete use case appears.AbstractMailingListProvider so additions stay non-breaking for existing provider classes.Themes can range from simple presentation layers to complex integrations with multiple plugins:
Basic Theme Structure:
/theme/my-theme/
├── theme.json # Theme metadata and configuration
├── serve.php # Theme routing (optional)
├── views/ # Theme templates and view overrides
│ ├── index.php
│ ├── page.php
│ └── plugin_overrides/ # Plugin view overrides
├── assets/ # Theme assets
│ ├── css/
│ ├── js/
│ └── images/
└── includes/ # Theme-specific classes
├── PublicPage.php # Theme-specific PublicPage implementation
└── FormWriter.php # Theme-specific FormWriter (optional)Advanced Theme with Plugin Integration:
/theme/advanced-theme/
├── theme.json
├── serve.php # Includes plugin routes
├── views/
│ ├── index.php
│ ├── items/ # Plugin view overrides
│ │ ├── list.php
│ │ └── detail.php
│ └── profile/ # Plugin view overrides
│ └── dashboard.php
├── assets/
└── includes/
├── PublicPage.php # Bootstrap/UIKit/WordPress-specific implementation
└── ThemeHelper.php # Theme-specific utilitiesThemes can define their own routes in RouteHelper format, including integration with plugin functionality:
Basic Theme Routing:
// theme/my-theme/serve.php
$routes = [
'dynamic' => [
// Simple view routes (uses view resolution chain)
'/my-page' => ['view' => 'views/my_page'],
'/about' => ['view' => 'views/about'],
// Model-based routes using plugin data
'/item/{slug}' => [
'model' => 'Item',
'model_file' => 'plugins/items/data/items_class'
],
],
'custom' => [
// Complex routing logic
'/custom-handler' => function($params, $settings, $session, $template_directory) {
// Custom logic here
require_once(PathHelper::getThemeFilePath('custom.php', 'views'));
return true;
},
],
];Plugin serve.php (namespaced routes only):
// plugins/controld/serve.php
$routes = [
'dynamic' => [
// Routes must be within the plugin's namespace
'/profile/controld/device_edit' => [
'view' => 'views/profile/device_edit',
'min_permission' => 0,
],
'/controld/create_account' => [
'view' => 'views/create_account',
],
],
];Note: The plugin name is extracted automatically from the URL pattern — no plugin_specify field is needed or supported.
Themes integrate with plugin backend services through data models and the view resolution system:
Using Plugin Data Models:
// theme/my-theme/views/items.php
<?php
require_once(PathHelper::getIncludePath('plugins/items/data/items_class.php'));
// Use plugin data models
$items = new MultiItem(['itm_active' => 1], ['itm_name' => 'ASC']);
$items->load();
foreach ($items as $item) {
echo '<h3>' . $item->get('itm_name') . '</h3>';
echo '<p>' . $item->get('itm_description') . '</p>';
}
?>View Override Pattern:
// theme/my-theme/views/items/list.php - Overrides plugin view
<?php
// This theme view will be used instead of plugins/items/views/items/list.php
// But can still access plugin data models and helpers
require_once(PathHelper::getIncludePath('plugins/items/data/items_class.php'));
require_once(PathHelper::getIncludePath('plugins/items/includes/ItemsHelper.php'));
$items = ItemsHelper::getActiveItems();
foreach ($items as $item) {
// Theme-specific presentation
include 'item_card_template.php';
}
?>Theme-Specific Class Integration:
// theme/bootstrap-theme/includes/PublicPage.php
class PublicPage extends PublicPageBase {
protected function getTableClasses() {
return [
'wrapper' => 'table-responsive',
'table' => 'table table-striped table-hover',
'header' => 'thead-dark'
];
}
// Bootstrap-specific implementations
public function renderAlert($message, $type = 'info') {
return "<div class='alert alert-{$type}' role='alert'>{$message}</div>";
}
}Profile/Member Area:
Profile pages (/profile/*) and /notifications use the active theme's PublicPage directly — no separate MemberPage wrapper. Profile views call $page->public_header() / $page->public_footer() like any other public view and render their content inside a .jy-ui scope using the jy-ui kit components (.jy-panel, .jy-page-header, .jy-breadcrumbs, .card, etc.). In-page navigation between profile sub-pages is handled by the existing user dropdown in the theme header and, where relevant, a per-page PublicPage::tab_menu() tab bar.
Theme assets are served through the theme asset route with automatic caching:
/theme/{theme}/assets/*
Basic Asset Usage:
// In theme templates
<link rel="stylesheet" href="/theme/<?= $template_directory ?>/assets/css/style.css">
<script src="/theme/<?= $template_directory ?>/assets/js/app.js"></script>
<img src="/theme/<?= $template_directory ?>/assets/images/logo.png" alt="Logo">Base Assets:
PublicPageBase loads fallback CSS/JS (base.css, joinery-styles.css, base.js) via the render_base_assets() method, called from global_includes_top(). Themes that provide their own complete CSS (like PublicPageJoinerySystem) override render_base_assets() with an empty body to prevent style conflicts. See Theme Integration Instructions for details.
Using ThemeHelper for Assets:
// Enhanced asset management
<?php
$theme = ThemeHelper::getInstance();
?>
<link rel="stylesheet" href="<?= $theme->asset('css/bootstrap.min.css') ?>">
<link rel="stylesheet" href="<?= $theme->asset('css/theme.css') ?>">
<script src="<?= $theme->asset('js/theme.js') ?>"></script>Theme Configuration:
// Using theme.json configuration in templates
<?php
$theme_config = ThemeHelper::config('cssFramework', 'bootstrap');
if ($theme_config === 'bootstrap') {
echo '<div class="container">';
} elseif ($theme_config === 'uikit') {
echo '<div class="uk-container">';
}
?>All themes should include a theme.json file for proper system integration.
Two boolean flags control how a theme moves between the publisher and customer
sites. Both default to true if missing, but should be declared explicitly:
receives_upgrades — true, the
on-disk copy is replaced from the upgrade payload during a deploy swap and
the container reconciler will re-download it on boot if it goes missing.
Set to false to keep a hand-edited copy across deploys. Mirrored to the
database (thm_receives_upgrades); the admin Themes page can toggle it.included_in_publish — true,
publish_upgrade.php packages this theme into the upgrade archive and the
marketplace catalog advertises it. If false, it is skipped. Manifest-only
(no DB column, no admin UI).
For a freshly authored site theme that should stay on its origin site and not
ship downstream, set both flags to false. For a theme published via the
upgrade pipeline, set both to true. The same pair applies to plugin.json.Basic theme.json:
{
"name": "my-theme",
"displayName": "My Custom Theme",
"version": "1.0.0",
"description": "A custom theme for my site",
"author": "Your Name",
"receives_upgrades": false,
"included_in_publish": false,
"requires": {
"php": ">=7.4",
"joinery": ">=1.0.0"
},
"cssFramework": "bootstrap",
"formWriterBase": "FormWriterV2HTML5",
"publicPageBase": "PublicPageBase"
}Plugin-integrated theme.json:
{
"name": "advanced-theme",
"displayName": "Advanced Plugin-Integrated Theme",
"version": "2.1.0",
"description": "Theme with full plugin integration",
"author": "Developer Team",
"receives_upgrades": false,
"included_in_publish": false,
"requires": {
"php": ">=8.0",
"joinery": ">=1.0.0"
},
"supports_plugins": ["controld", "items"],
"cssFramework": "bootstrap",
"formWriterBase": "FormWriterV2HTML5",
"publicPageBase": "PublicPageBase",
"features": {
"responsive": true,
"dark_mode": true,
"plugin_integration": true
}
}HTML5 framework-agnostic theme.json:
{
"name": "custom-theme",
"displayName": "Custom HTML5 Theme",
"version": "1.0.0",
"description": "Framework-agnostic theme with custom styling",
"author": "Developer",
"receives_upgrades": false,
"included_in_publish": false,
"requires": {
"php": ">=7.4",
"joinery": ">=1.0.0"
},
"cssFramework": "html5",
"formWriterBase": "FormWriterV2HTML5",
"publicPageBase": "PublicPageBase"
}Theme with plugin dependencies (requires_plugins):
{
"name": "scrolldaddy-theme",
"displayName": "ScrollDaddy Theme",
"version": "1.0.0",
"requires_plugins": ["scrolldaddy"],
"cssFramework": "html5",
"formWriterBase": "FormWriterV2HTML5",
"publicPageBase": "PublicPageBase"
}The requires_plugins field declares plugins that must be active for the theme to work correctly. When present:
requires_plugins (with an error directing the admin to switch themes first).Themes also support the deprecated and superseded_by fields described in the plugin.json Deprecation Fields section above. The behavior is identical for themes and plugins.
Get Active Theme:
$current_theme = ThemeHelper::getActive();Get Theme Configuration:
$css_framework = ThemeHelper::config('cssFramework', 'bootstrap', 'theme-name');
$supports_plugins = ThemeHelper::config('supports_plugins', [], 'theme-name');/plugins/{plugin}/admin/*In views with PublicPage available (most frontend views):
// Preferred method in views - uses PublicPage wrapper
$formwriter = $page->getFormWriter('form1');In different contexts:
// Admin pages - use the page object
$formwriter = $page->getFormWriter('form1'); // $page is AdminPage instance
// Utilities and logic files - direct instantiation
require_once(PathHelper::getThemeFilePath('FormWriter.php', 'includes'));
$formwriter = new FormWriter('form1');The $page->getFormWriter() method automatically:
FormWriter.php if it provides oneFormWriterV2HTML5 renderer otherwiseFormWriterV2HTML5, regardless of the theme's cssFramework.FormWriter.php extends FormWriterV2HTML5 (or FormWriterV2Base) to add theme-specific overrides.Before (Plugin served routes):
// plugins/controld/serve.php (REMOVED)
$routes = [
'/profile/device_edit' => ['view' => 'views/profile/ctlddevice_edit'],
'/create_account' => ['view' => 'views/create_account'],
];After (Theme serves routes):
// theme/sassa/serve.php (CURRENT)
$routes = [
'dynamic' => [
'/profile/device_edit' => ['view' => 'views/profile/ctlddevice_edit'],
'/pricing' => ['view' => 'views/pricing'],
],
];Plugin now only provides:
/plugins/controld/admin/*CtldAccount, CtldDevice, etc.ControlDHelper class and logic filesTwo methods for including files:
PathHelper::getIncludePath() - Direct loading, no overrides
require_once(PathHelper::getIncludePath('data/user_class.php')); // Data models
require_once(PathHelper::getIncludePath('includes/MyHelper.php')); // System filesPathHelper::getThemeFilePath() - Theme-aware file resolution with override chain
// Files that can be overridden by themes
require_once(PathHelper::getThemeFilePath('profile_logic.php', 'logic'));
require_once(PathHelper::getThemeFilePath('devices.php', 'views/profile'));
// With explicit plugin context (5th parameter)
require_once(PathHelper::getThemeFilePath('devices.php', 'views/profile', 'system', null, 'controld'));
// Parameters: filename, subdirectory, path_format, theme_name, plugin_name
Override chain: theme → plugin → basePathHelper::getIncludePath(): Direct file access for system files, data models, plugin filesPathHelper::getIncludePath(): Direct file access, no theme overrides needed (plugins, data files)PathHelper::getThemeFilePath(): Files that themes/plugins can override (views, logic, includes)Important: The file override system uses PathHelper::getThemeFilePath() which checks:
/theme/{theme}/{subdirectory}/{filename}/plugins/{plugin}/{subdirectory}/{filename}/{subdirectory}/{filename}/plugins/{name}/ with plugin.jsonplugins/{name}/data/ with $field_specifications (tables created automatically on install)plugin.json under the adminMenu key (see Admin Menus)plugin.json under the settings key (see Plugin Settings).sql migration files in plugins/{name}/migrations/ only if you have other initial data seeds (dropdowns, categories, reference rows)plugins/{name}/admin/ if neededuninstall.php only if you have external cleanup to perform (API calls, filesystem, remote-service notifications) — the system handles tables, settings, menus, and scaffolding automatically. See Uninstall Script./plugins/{plugin}/admin/*/plugins/{plugin}/admin/*Enable route debugging with URL parameter:
http://example.com/any-page?debug_routes=1This shows detailed routing information in HTML comments.
404 on plugin admin pages:
plugins/{plugin}/admin//)/profile/{pluginname}/... pattern and file exists at plugins/{pluginname}/views/profile/....phpFormWriter.php extends FormWriterV2HTML5 (the HTML renderer used by every theme)theme/{theme}/assets/If your plugin adds analytics or marketing scripts to public pages, you should wrap them for GDPR/CCPA consent compliance.
Using ConsentHelper to wrap scripts:
require_once(PathHelper::getIncludePath('includes/ConsentHelper.php'));
$consent = ConsentHelper::get_instance();
echo $consent->wrapTrackingCode('<script>...your tracking code...</script>', 'analytics');Or manually add the consent attribute to script tags:
<script type="text/plain" data-joinery-consent="analytics">
// This script only runs after user consents to analytics
</script>Consent categories:
analytics - For analytics and tracking scripts (e.g., Google Analytics)marketing - For advertising and remarketing scripts (e.g., Facebook Pixel)data-joinery-consent remain inactive until the user grants consent for that category.A theme's cssFramework declares which CSS its chrome loads; FormWriter is unaffected and always renders semantic HTML5 via FormWriterV2HTML5.
Bootstrap Themes:
bootstraptable, table-striped, table-hovercontainer, container-fluidhtml5 or customFormWriterV2HTML5; a theme may extend it (or FormWriterV2Base) for custom output.PublicPage Class Implementations:
// Bootstrap theme
protected function getTableClasses() {
return [
'wrapper' => 'table-responsive',
'table' => 'table table-striped table-hover',
'header' => 'thead-dark'
];
}
// UIKit theme
protected function getTableClasses() {
return [
'wrapper' => 'uk-overflow-auto',
'table' => 'uk-table uk-table-striped',
'header' => 'uk-table-header'
];
}
// WordPress theme
protected function getTableClasses() {
return [
'wrapper' => 'table-wrapper',
'table' => 'wp-list-table widefat fixed striped',
'header' => 'thead'
];
}ControlD (Backend-only)
/plugins/controld//plugins/controld/admin/*/plugins/items//plugins/items/admin/*Sassa Theme (Plugin-enabled, Bootstrap)
bootstrap/profile/*, /pricing/items, /item/{slug}/theme/sassa/serve.phpwordpressuikit/plugins/{name}/admin/*$this->render_notification_icon($menu_data) in top_right_menu() for notifications; override only if theme needs different markupThe plugin theme system allows plugins to act as complete theme providers, replacing the entire user interface while maintaining all plugin functionality. This enables white-label solutions, complete UI replacements, and branded experiences.
/[plugin-name]/*/plugins/bookings/
├── plugin.json
├── serve.php
├── admin/
│ └── manage_bookings.php
├── views/
│ └── booking_list.php
└── assets/
└── js/bookings.jsRequired Files:
/plugins/controld/
├── plugin.json (with "provides_theme": true)
├── serve.php
├── includes/
│ ├── PublicPage.php (required - base page class)
│ └── FormWriter.php (required - form generation)
├── views/
│ ├── index.php (homepage view)
│ ├── profile.php (user profile)
│ └── [other system view overrides]
└── assets/
├── css/style.css
├── js/main.js
└── img/logo.pngHow Theme Provider Mode Works:
/plugins/controld/includes/
- RouteHelper loads views from /plugins/controld/views/
- ThemeHelper loads assets from /plugins/controld/assets/Behavior Modes:
active_theme_plugin
theme_template = 'plugin''controld' to use ControlD plugin as themetheme_template
'plugin' - Delegates all theme functionality to a plugin'falcon', 'sassa', 'tailwind', etc./adm/admin_settings.php)Theme Selection Enhancement: When "Plugin-Provided Theme" is selected from the theme dropdown:
"provides_theme": true are prioritizedWhen plugin theme is active, the system checks for files in this order:
For PHP Classes (via PathHelper):
/plugins/{active_plugin}/includes/{file}/theme/plugin/includes/{file} (fallback)/includes/{file} (system fallback)/plugins/{active_plugin}/views/{file}/views/{file} (system fallback)/plugins/{active_plugin}/assets/{file}/theme/plugin/assets/{file} (shouldn't exist)active_theme_plugin settingis_dir() and file_exists() checks