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 |
| 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 = new Session($settings);
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');Plugin directory names appear directly in user-facing URLs (/{pluginname}/*, /profile/{pluginname}/*, /admin/{pluginname}/*), so choose them carefully:
events, billing, usersscrolldaddy, inbound_emailinbound_email not InboundEmailprofile, 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/ # AJAX endpoints
├── 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"]
},
"depends": {
"core-plugin": ">=1.0"
},
"provides": ["api-endpoint", "widget-support"],
"tags": ["utility", "api", "backend"]
}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:
// 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_id';
public static $field_specifications = [
'mdt_id' => ['required' => true, 'type' => 'int'],
'mdt_name' => ['required' => true, 'type' => 'varchar', 'length' => 255],
'mdt_description' => ['type' => 'text'],
'mdt_created' => ['type' => 'timestamp', 'default' => 'now()']
];
// Define foreign key behavior (optional - defaults to cascade)
protected static $foreign_key_actions = [
'mdt_usr_user_id' => ['action' => 'set_value', 'value' => User::USER_DELETED]
];
}Deletion Behavior: For complete documentation on defining foreign key actions, cascading deletes, soft-delete cascading patterns, and undelete strategies, see the Deletion System Documentation.
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__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();
?>Plugins declare menu contributions in plugin.json under two keys:
adminMenu — items in the admin sidebar (/admin/*).profileMenu — items in the user dropdown shown by themes (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.Locations:
| Location | Source key | Permission floor | Visibility |
|---|---|---|---|
admin_sidebar | adminMenu | ≥ 1 | always in (logged in) |
user_dropdown | profileMenu | ≥ 0 | in / out / both |
[a-z0-9-], max 32 chars, unique within the plugin.<plugin-name>- (e.g. mybooks-shelf). For adminMenu, this is recommended; for profileMenu, it is required by validation.core- — that prefix is reserved for core menu rows seeded by migrations.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": "incoming",
"title": "Incoming",
"url": "/plugins/inbound_email/admin/admin_inbound_email",
"parent": "emails",
"permission": 5,
"order": 10,
"settingActivate": "inbound_email_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 ([a-z0-9-], max 32). Must start with <plugin-name>-. |
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. |
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. |
name must start with the plugin's directory name (e.g., a plugin at /plugins/bookings/ must declare settings named bookings_*).name may collide with a core setting in settings.json at the public_html/ root.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 null 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.
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: modify the class, then run Sync with Filesystem from the admin Plugins page (/admin/admin_plugins?action=sync_filesystem). Sync calls runPluginTablesOnly() for all active plugins, which picks up new columns and creates new tables. 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()PluginHelper::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)'),
);
}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 database side of plugin setup. Provisioners handle the other side: the external runtime resources a plugin needs that the database system knows nothing about — mail servers, relays, services, extensions, APIs.
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 system only detects and reports — it never runs a 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 reaches out to acquire (an SMTP relay, a database, an extension). The check IS your plugin's real acquisition routine, invoked on demand: it exercises the exact code path the feature uses, and it works the same inside a container or on bare metal because it only asks "can our code acquire this." A 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 pushes into your plugin rather than being acquired by it (an inbound mail server that pipes mail to a script — your code never connects to it, so a 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 expected failure signal. Catch the underlying acquisition exception (a 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 ajax/check_provisioning.php) 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 — customer-side, deploy preservation. If 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 — publisher-side, packaging filter. If 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).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": "FormWriterV2Bootstrap",
"publicPageBase": "PublicPageBase"
}Tailwind 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": "tailwind",
"formWriterBase": "FormWriterV2Tailwind",
"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:
FormWriterV2BootstrapFormWriterV2TailwindFormWriterV2HTML5 (framework-agnostic)FormWriterV2Base for custom implementationsBefore (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/....phptheme/{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.The system supports multiple CSS frameworks through theme-specific implementations:
Bootstrap Themes:
bootstrapFormWriterV2Bootstraptable, table-striped, table-hovercontainer, container-fluidtailwindFormWriterV2Tailwindcontainer, mx-autohtml5 or customFormWriterV2HTML5PublicPage 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