Plugin Anatomy
This page explains the structure of a plugin in detail: which folders exist, which required methods you must implement, and how the class plugs into the core.
Directory structure
_public/extensions/core/backend/{plugin-name}/
├── bootstrap.php # Plugin class + registration (REQUIRED)
├── layout/ # Vue admin components (optional)
│ ├── index.vue # List / template view
│ ├── index_detail.vue # Detail / edit view
│ └── {subview}.vue # Row renderer for show_module_list
├── script/ # PHP action scripts (optional)
│ ├── csvExport.php
│ └── {path}/{name}.php
├── api/ # Plugin-local API models (optional)
│ ├── backend/{name}/model.php
│ └── {public}/{name}/model.php
├── widgets/ # Pagebuilder widgets (optional)
│ └── {widget-name}/
│ ├── bootstrap.php
│ └── template/index.vue
├── migrations/ # Plugin-local SQL migrations (optional)
│ └── 001_create_tables.sql
├── searchIndex/ # Elasticsearch index scripts (optional)
├── scheduledTasks/ # Cron script files (optional)
├── src/ # Frontend assets (optional)
│ └── css/
└── config/ # JSON configs (optional)Only bootstrap.php is required. Every other folder is optional.
bootstrap.php — the plugin class
The main file contains exactly one class following the pattern {plugin-name}_BackendPlugin and extending install_controller:
<?php
class menueditor_BackendPlugin extends install_controller
{
public static function getVersion(): string
{
return "1.0.0";
}
public static function getName(): string
{
return "Menu Editor";
}
public function install()
{
$this->url_rewrites_backend = ["menueditor"];
$this->content_construct = ["template"];
$this->content_table = [""];
$this->backend_widget_base = ["core/backend/menueditor"];
$this->content_titles = ["Menu Editor"];
$this->content_columns = [""];
$this->content_button = ['[]'];
$this->action_button = ['[]'];
$this->content_replace = ['{}'];
$this->header_buttons = ['[]'];
$this->modal_edits = ['{}'];
$this->icon = "fa-light fa-bars-staggered";
$this->menu_group = 0;
$this->apiEndpoints = [
'{"modelPath": "backend/menueditor/model.php", "endpoint": "backend/menueditor"}',
'{"modelPath": "menueditor/model.php", "endpoint": "menueditor"}'
];
}
public function uninstall()
{
// Leave empty — the core install_controller removes plugin_backend entries
}
public static function update()
{
// Only populate when data migrations on plugin_backend are needed.
// Schema migrations belong in migrations/ (see Plugins › Migrations).
}
public function getDirectory()
{
return $this->getDirectoryClass(__DIR__);
}
}Real-world example: _public/extensions/core/backend/menueditor/bootstrap.php.
Class naming convention
The class name must be {root_folder}_BackendPlugin (root folder = directory name under _public/extensions/core/backend/). The Plugin Manager instantiates the class via new {root_folder}_BackendPlugin() — any deviation causes a Class not found fatal and install fails.
Required and recommended methods
The base class install_controller defines five required methods that every plugin must override, plus one recommended method for asset/layout resolution.
Required (5)
| Method | Signature | Purpose |
|---|---|---|
getName() | public static function getName(): string | UI label in the Plugin Manager |
getVersion() | public static function getVersion(): string | Semver, stored in plugins.version |
install() | public function install() | Set properties, called by core after new Plugin() |
uninstall() | public function uninstall() | Cleanup hook (usually empty — the core removes plugin_backend entries automatically) |
update() | public static function update() | Data migrations on a system update. Schema changes belong in Migrations, typically empty |
Recommended (1)
| Method | Signature | Purpose |
|---|---|---|
getDirectory() | public function getDirectory() | Resolves the plugin folder via __DIR__ — essential for asset paths and layout resolution |
The recommended pattern is a one-liner:
public function getDirectory()
{
return $this->getDirectoryClass(__DIR__);
}Without this method, getDirectory() calls from the core fall back to the install_controller default — fine for minimal plugins without own assets, but effectively required for plugins with widgets or custom layouts.
Registration properties
Inside install(), you set properties on the base class. All are public and read by the installer:
| Property | Type | Purpose |
|---|---|---|
$url_rewrites_backend | array<string> | URL slug per content construct → /admin/{slug} |
$content_construct | array<string> | table, template, or formonly — see Content Constructs |
$content_table | array<string> | DB table when using the table construct |
$content_titles | array<string> | UI label |
$content_columns | array<string> | Visible columns in the list (comma-separated) |
$backend_widget_base | array<string> | Path to the widget entry point per construct (e.g. core/backend/{plugin}) |
$content_button | array<string> | Row buttons (JSON string) |
$action_button | array<string> | Header action button (JSON string) |
$header_buttons | array<string> | Additional header buttons |
$content_replace | array<string> | Column renames |
$modal_edits | array<string> | Modal field config |
$icon | string | Font Awesome class for navigation |
$menu_group | int | Grouping in the admin sidebar |
$apiEndpoints | array<string> | JSON strings, one per endpoint — see API Endpoints |
$scheduledTasks | array<array> | Cron jobs — see Scheduled Tasks |
$webhookEvents | array<string> | Manual events — see Webhook Events |
$pagebuilder_widgets | array<string> | Pagebuilder widgets shipped by the plugin |
Arrays, not scalars
All content_* properties are arrays — one entry per content_construct. A plugin can have multiple entry points (e.g. a table view and a template view). Even with a single entry, you must stick to the array format — otherwise the for iteration in installBackend() breaks and empty rows end up in plugin_backend.
The installer flow
After install(), the core runs these installers in sequence (all in install_controller.php):
installPlugin($root_folder, $location)
└─ install() # Your code, sets properties
└─ installBackend($pluginID) # plugin_backend entries
└─ installWidgets($pluginID) # pagebuilder_widgets + page_widgets
└─ installDatabase() # Plugin-local SQL via MigrationRunner
└─ installInjections($pluginID) # Register script hooks (PreInjection/MidInjection/PostInjection/CacheInjection)
└─ installSources($pluginID, …) # CSS/JS assets
└─ installAPI($pluginID) # pluginAPI table
└─ installScheduledTasks($pluginID)
└─ installWebhookEvents($pluginID)Your plugin writes no inserts by hand — the core installers take care of that. Your job is just to set the properties correctly.
onLoad() — template plugins
For content_construct=template, a plugin can implement an onLoad() method that returns real JSON. The return value is delivered as content in the response of the admin page endpoint and is available inside the frontend template:
public function onLoad()
{
parent::onLoad();
$menus = [];
$q = query("SELECT * FROM menueditor_menus ORDER BY id ASC");
while ($row = fetch_assoc($q)) {
$menus[] = $row;
}
if ($GLOBALS['isApiCall']) {
return json_encode(['menus' => $menus]);
}
return "[]";
}The original in the menueditor plugin additionally loads the language list and populates $GLOBALS['smarty'] in the else branch for legacy Smarty rendering. The snippet above is trimmed for the Nuxt admin API — only the $GLOBALS['isApiCall'] branch matters when you don't need Smarty compatibility.
onLoad() must return valid JSON
An empty string "" or the literal "[]" are fine, but never return PHP arrays or objects directly — the core echoes the value unfiltered, and the frontend expects parseable JSON.
getContent() — custom content buttons
For buttons of type "show_custom_content", the getContent() method supplies data for a free-form detail area:
public function getContent(): array
{
return [
'stats' => $this->loadStats(),
'recent_logs' => $this->loadRecentLogs(),
];
}Details in Buttons.
Database helpers
Plugins use the helpers from include/mysql.php — never mysqli_* or PDO directly:
query("SELECT ..."); // runs against the active connection
fetch_assoc($q); // one record
fetch_all($q); // all records
num_rows($q); // row count
insert_id(); // ID after INSERT — NOT last_insert_id()!
real_escape_string($value); // escape for stringsUse insert_id(), not last_insert_id()
last_insert_id() does not exist in this CMS. Always use insert_id() after an INSERT.
Common issues
Class name doesn't match the folder
Folder shop → class shop_BackendPlugin. Any deviation (e.g. Shop_BackendPlugin or shopPlugin) makes installPlugin() fail — the plugin shows up in the Manager but refuses to install.
content_* properties are not arrays
Even for a single entry, content_construct, content_table, url_rewrites_backend, etc. must be arrays. Scalar values produce empty rows in plugin_backend.
onLoad() returns a PHP array
The core echoes the return value unchanged. PHP objects/arrays become the literal string Array → frontend JSON parse error. Always use json_encode().
See also
- Plugins overview — what a plugin is, its lifecycle
- Content Constructs —
table/template/formonly - Buttons — all button types
- API Endpoints —
$this->apiEndpointsand class naming - Example: Hello World — full minimal plugin