Building a FOG Plugin — Start to Finish¶
This guide walks you from an empty directory to a working, installable FOG
plugin on the working‑1.6 framework. It uses a complete, runnable example
plugin — helloworld — that ships alongside this document at
packages/web/lib/plugins/helloworld/.
Copy that directory, rename it, and you have a head start.
Scope: this targets the working‑1.6 plugin framework (the
formFields/
makeInputpage helpers, theaddPost/editPostJSON pattern, and the
non‑destructiveschema()migration contract). The 1.5.x line renders pages
differently (raw HTML strings,*page.class.phpfile names) and lacks the
schema()migration mechanism; this guide does not cover it.
1. What a plugin is¶
A FOG plugin is just a directory under packages/web/lib/plugins/<name>/
containing PHP classes that FOG auto‑discovers. There is no build step and no
registration list to edit — drop the directory in, activate the plugin in the
UI (Plugin Management), and it works.
A typical plugin provides:
- a model (one entity / table row),
- a manager (the table + its migrations),
- a page (the UI and its form POST handlers),
- hooks (menu entry, JS injection, API exposure, …),
- JS files (one per sub‑page).
The running example, helloworld, manages a trivial entity with a name and a
description, end to end.
2. Mental model (how the pieces connect)¶
- Boot chain. Every entry point loads
commons/base.inc.php→
commons/init.php→LoadGlobals, which sets the shared singletons
(FOGBase::$DB,$HookManager,$EventManager,$currentUser). - Autoloader.
InitiatorscansBASEPATHrecursively, adds every
directory containing a*.{class,page,hook,event,report}.phpfile to the PHP
include_path, then registers PHP's defaultspl_autoload. That default
autoloader lowercases the class name to find the file. So:
The filename must be
strtolower(ClassName)+ the suffix.
class HelloWorldManagement⇒helloworldmanagement.page.php.
class AddHelloWorldJS⇒addhelloworldjs.hook.php.
(Class names in code are PascalCase; the files on disk are all‑lowercase.)
- Routing. The whole UI is driven by ?node=<x>&sub=<y>&id=<n>. node maps
to a page class (helloworld → HelloWorldManagement, matched by its
public $node = 'helloworld'), and sub maps to a method on it
(sub=add → add(), sub=addPost → addPost(), sub=list → the inherited
DataTables list).
- ORM. Models declare $databaseTable and $databaseFields
(friendly‑name → column). You then use get()/set()/save()/load()/destroy(),
or new HelloWorld(42) to auto‑load by id.
- Hooks/events. Cross‑cutting integration is done by registering callbacks
against named events: self::$HookManager->register('EVENT', [$this, 'fn'])
and firing with processEvent('EVENT', ['data' => &$data]).
3. Directory layout¶
packages/web/lib/plugins/helloworld/
├── config/
│ └── plugin.config.php # discovery metadata ($fog_plugin[...])
├── class/
│ ├── helloworld.class.php # HelloWorld (model, FOGController)
│ └── helloworldmanager.class.php # HelloWorldManager (manager + schema())
├── pages/
│ └── helloworldmanagement.page.php # HelloWorldManagement (FOGPage)
├── hooks/
│ ├── addhelloworldmenuitem.hook.php # menu entry + search/objects
│ ├── addhelloworldjs.hook.php # JS injection
│ └── addhelloworldapi.hook.php # REST API exposure
└── js/
├── fog.helloworld.list.js
├── fog.helloworld.add.js
└── fog.helloworld.edit.js
The directory name is the plugin's machine name and routing node. Keep it
lowercase and use it consistently ($fog_plugin['name'], each hook's
public $node, the page's public $node).
4. Step by step¶
4.1 config/plugin.config.php¶
Discovery metadata. Plugin::getPlugins() includes this file and reads the
$fog_plugin array.
$fog_plugin = [];
$fog_plugin['name'] = 'helloworld'; // == directory name
$fog_plugin['description'] = 'Skeleton example plugin …';
$fog_plugin['menuicon'] = 'fa fa-cube fa-fw'; // "fa …" => icon; else <img src>
$fog_plugin['menuicon_hover'] = null;
$fog_plugin['entrypoint'] = 'html/run.php'; // legacy/conventional; not shipped
The
entrypointis vestigial — no plugin actually shipshtml/run.php;
routing happens through thenode→ page‑class mapping. Declare it anyway for
consistency with every other plugin.
4.2 Model — class/helloworld.class.php¶
class HelloWorld extends FOGController
{
protected $databaseTable = 'helloWorld';
protected $databaseFields = [
'id' => 'hwID',
'name' => 'hwName',
'description' => 'hwDesc',
];
protected $databaseFieldsRequired = ['name'];
}
That's the entire ORM contract. $databaseFields maps friendly names (used in
code and in the API) to real column names. $databaseFieldsRequired is enforced
on save().
4.3 Manager + migrations — class/helloworldmanager.class.php¶
The manager owns table creation and schema evolution. This is the most
important part to get right, so it gets its own section (§5). The shape:
class HelloWorldManager extends FOGManagerController
{
public $tablename = 'helloWorld';
public function createSql() { return Schema::createTable(/* … */); }
public function schema()
{
return [
$this->createSql(), // step 0 — create the table
// append future steps here, never reorder/remove
];
}
public function install()
{
$res = Schema::applyUpdates($this->schema(), 0);
return $res['error'] === null;
}
}
4.4 Page — pages/helloworldmanagement.page.php¶
The page extends FOGPage, declares public $node = 'helloworld', and sets the
list columns in its constructor:
public function __construct($name = '')
{
$this->name = 'Hello World Management';
parent::__construct($this->name);
$this->headerData = [_('Name'), _('Description')];
$this->attributes = [[], []];
}
You do not write a list/index() method — FOGPage provides it. The list
page renders a DataTable whose JSON comes from ?node=helloworld&sub=list; the
columns are produced by the router from your model fields, so the column keys
available to the JS are mainlink (the linked name), id, and every field by
its friendly name (here description).
Forms are built with helpers and rendered with formFields():
| Helper | Purpose |
|---|---|
self::makeFormTag(...) |
opening <form> |
self::makeLabel($class, $for, $text) |
a <label> |
self::makeInput($class, $name, $placeholder, $type, $id, $value, $required) |
an <input> |
self::makeTextarea(...) |
a <textarea> |
self::makeButton($id, $text, $class) |
a <button> |
self::selectForm($name, $items, $selected, ...) |
a <select> |
self::formFields($fields) |
renders a [label => field] array |
self::tabFields($tabData, $obj) |
the tabbed edit layout |
self::makeTabUpdateURL($tab, $id) |
the POST URL for a tab |
The POST pattern. addPost() and editPost() return JSON and follow the
same skeleton every time:
public function addPost()
{
self::checkAuthAndCSRF(); // ALWAYS first
header('Content-type: application/json');
$name = trim(filter_input(INPUT_POST, 'name')); // never raw $_POST
$serverFault = false;
try {
// validate, then build + save the model …
if (!$obj->save()) { $serverFault = true; throw new Exception(_('…')); }
$code = HTTPResponseCodes::HTTP_CREATED;
$msg = json_encode(['msg' => _('…'), 'title' => _('…')]);
} catch (Exception $e) {
$code = $serverFault
? HTTPResponseCodes::HTTP_INTERNAL_SERVER_ERROR // 500 = our fault
: HTTPResponseCodes::HTTP_BAD_REQUEST; // 400 = bad input
$msg = json_encode(['error' => $e->getMessage(), 'title' => _('…')]);
}
http_response_code($code);
echo $msg;
exit;
}
Set
$serverFault = trueonly when the failure is server‑side (a failed
save()), so genuine failures return500and validation errors return
400. Getting this backwards is a real bug we've fixed before.
The edit page uses tabs. edit() builds $tabData and calls
tabFields(); each tab has a generator closure that renders its body
(helloworldGeneral()), and editPost() dispatches on the global $tab to the
matching *GeneralPost() that mutates $this->obj before the shared save().
4.5 Hooks — hooks/*.hook.php¶
Each hook is a small class extending Hook, with public $node, that registers
callbacks in its constructor, guarded by the install check:
public function __construct()
{
parent::__construct();
if (!in_array($this->node, (array)self::$pluginsinstalled)) {
return; // do nothing unless this plugin is installed
}
self::$HookManager->register('MAIN_MENU_DATA', [$this, 'menuData']);
}
The example ships three hooks:
- Menu (
AddHelloWorldMenuItem) —MAIN_MENU_DATAadds the sidebar entry;
SEARCH_PAGESmakes it searchable;PAGES_WITH_OBJECTSenables the
edit/delete object flow. (SUB_MENULINK_DATAwould add extra sub‑links such
as Export/Import — omitted here.) - JS (
AddHelloWorldJS) —PAGE_JS_FILESinjectsfog.<node>.<sub>.jsfor
the current sub‑page. - API (
AddHelloWorldAPI) —API_VALID_CLASSESexposes the node over REST
so/fog/helloworldreuses the same ORM as the UI.
4.6 JavaScript — js/fog.helloworld.*.js¶
One file per sub‑page (list, add, edit), each an IIFE. The list file
registers the server‑side DataTable and the create modal; its columns[].data
keys must match the list endpoint (mainlink, then your field names) and their
order must match $headerData. The add/edit files wire the form buttons
to processForm() (which POSTs and shows notifications) and, on edit, the delete
confirm modal to $.apiCall(... &sub=delete ...).
Shared helpers you'll use: Common.node, Common.id, Common.search,
$.apiCall(), $.deleteSelected(), <form>.processForm(),
$('#dataTable').registerTable().
5. Database & migrations (the important part)¶
FOG has no automatic per‑column migration. Schema::createTable() emits
CREATE TABLE IF NOT EXISTS, which does nothing on a table that already
exists — so simply adding a column to createSql() will not reach existing
installs. Use the schema() contract instead.
schema() returns an ordered, append‑only list of steps. Each step is a SQL
string (or a closure returning SQL). On install/upgrade the framework
(Plugin::installdb()) calls:
Schema::applyUpdates($manager->schema(), $applied);
where $applied is the count stored in the plugin's pSchema column. Only
steps from index $applied onward run, and the new count is saved back. So:
To add a column later, append a new step. Never reorder or delete existing
steps — the applied count is positional.
public function schema()
{
return [
// 0 — create the table
$this->createSql(),
// 1 — added later; runs once on upgrade, skipped thereafter
"ALTER TABLE `helloWorld` ADD COLUMN `hwColor` VARCHAR(255) NULL",
];
}
applyUpdates() is defensive: it ignores "already exists / duplicate column /
duplicate key / unknown column / duplicate entry" errors, so re‑running is
safe. A closure step may return a string to signal a hard error and stop.
Seed data (e.g. default globalSettings rows) is just another step — return the
INSERT SQL, or a closure for anything that needs runtime values (see
accesscontrolmanager's schema() for the closure pattern).
Legacy note. Older plugins implement a destructive
install()that calls
uninstall()(drop) then recreates. New plugins should implementschema()
(the framework prefers it and falls back toinstall()only whenschema()
is absent). The example provides both; itsinstall()just applies the schema
from0.
6. Lifecycle¶
- Discovery.
Plugin::getPlugins()scans the plugins directory,includes
eachconfig/plugin.config.php, and upserts a row in thepluginstable. - Activation. An admin enables the plugin in Plugin Management. Its
nodeis added toFOGBase::$pluginsinstalled, which is what every hook
constructor checks before registering. - Install / upgrade.
Plugin::installdb()runsschema()via
applyUpdates()and trackspSchema. This is idempotent and
non‑destructive — safe to run on every upgrade. - Uninstall. Inherited
uninstall()drops the table; override it if you
need to clean up settings, associations, or users you created.
7. Settings¶
Global configuration lives in the globalSettings table.
- Read:
FOGBase::getSetting('FOG_PLUGIN_HELLOWORLD_FOO') - Write:
FOGBase::setSetting('FOG_PLUGIN_HELLOWORLD_FOO', $value) - Naming:
ALL_CAPS_WITH_UNDERSCORES, prefixedFOG_PLUGIN_<NAME>_…. - Create defaults as a
schema()seed step (anINSERTintoglobalSettings).
8. Security & output conventions¶
- Output: wrap every user‑controlled value with
Initiator::e($value)when
echoing into HTML. All output also passes through the global
sanitizeOutputbuffer. - Input: use
filter_input(INPUT_POST, 'key')(or the already‑sanitized
superglobals) — never raw$_POST/$_GET. - CSRF/auth: call
self::checkAuthAndCSRF()at the top of every state‑
changing POST handler. - Instantiation: prefer
self::getClass('HelloWorld')/
self::getClass('HelloWorldManager')overnew. - Translation: wrap UI strings in
_('…').
9. Common hook events¶
| Event | Purpose |
|---|---|
MAIN_MENU_DATA |
add the top‑level sidebar entry (hook_main[node] = [label, icon]) |
SUB_MENULINK_DATA |
add sub‑links (Export/Import/…) under the node |
SEARCH_PAGES |
make the node searchable |
PAGES_WITH_OBJECTS |
enable the object (edit/delete) flow for the node |
PAGE_JS_FILES |
inject JS files for the current page |
API_VALID_CLASSES |
expose the node over the REST API |
<NODE>_ADD_FIELDS / _GENERAL_FIELDS |
let others extend your forms |
<NODE>_ADD_POST / _EDIT_POST / _ADD_SUCCESS / _ADD_FAIL |
extension points around your saves |
Fire your own events with &‑by‑reference args so listeners can mutate them
(see the example's HELLOWORLD_* events).
10. Gotchas (learned the hard way)¶
CREATE TABLE IF NOT EXISTSnever alters a live table. Add columns via a
newschema()step, not by editingcreateSql().- Filename =
strtolower(ClassName)+ suffix. A mismatch means the class
silently won't autoload. menuiconbeginning withfais rendered as a font‑awesome icon;
anything else is treated as an<img>src.$serverFaultmust betrueonly for server‑side failures, so HTTP
status codes are honest (500vs400).- Hook constructors must early‑return when the node isn't in
$pluginsinstalled, or your hooks run for a plugin that isn't enabled. - List columns in the JS must match
$headerDataorder and the keys the
router emits (mainlink,id, friendly field names).
11. Install & test your plugin¶
- Copy
helloworld/topackages/web/lib/plugins/<yourname>/and rename the
directory, the classes, the files (lowercased), every$node, and the
$fog_plugin['name']. - Deploy to the web root (e.g.
copybacktrunk.sh "" "" "1.6"). - In the UI: Plugin System → Plugin Management → install/activate your
plugin. - Confirm: the sidebar entry appears, Create New saves a row (check the
table exists andpSchemaadvanced), list shows it, edit updates it,
delete removes it. - Quick static checks while developing:
php -l <file>on each PHP file andnode --check <file>on each JS file.
12. Reference plugins¶
helloworld— this guide's minimal, complete CRUD example.subnetgroup— a clean real CRUD plugin (model→class relationship,
Export/Import,schema()).accesscontrol— a multi‑table plugin showing a richerschema()with
seed and closure steps.ldap— authentication/integration plugin (custom hooks beyond CRUD).
When in doubt, copy the closest existing plugin and adapt it — the conventions
above are followed consistently across all of them.