Building a GhostStat Plugin
The complete guide to writing a plugin for GhostStat — matched to how the
platform actually loads and runs plugins (PluginManager), not a generic
template. Pair it with the ghoststat-plugin-starter skeleton: this
explains the why, the skeleton gives you working files to copy.
1. The plugin model in one minute
- Plugins live in
/public_html/plugins/<slug>/, one folder per plugin.
- The platform discovers them by scanning
plugins/*/manifest.json. Your
manifest must have a unique slug.
- A registry row is written to the
plugins table (slug, name,
version, scope, manifest_json, enabled) when the admin rescans.
- Only the admin installs/enables plugins. Availability to a user resolves as:
enabled = 1 AND ( scope = 'system' OR the user's plan/group was
granted it OR a per-user allow override ) AND NOT a per-user deny.
On top of that, a user can toggle a plugin off for a specific site, and
save per-site config. So: admin controls availability, the user controls
per-site enable + settings.
- Your
manifest.json plans block (free/pro/agent) is the intended
availability — a hint the admin follows when granting group access. Enforcement
is the group grant, e.g. "Pro + Agent, off for Free unless the admin overrides."
2. Folder structure
plugins/<slug>/
├── manifest.json # identity + declarations (slug, scope, hooks, routes, plans, i18n)
├── backend/
│ ├── logic.php # DEFINES + RETURNS the plugin instance (all hooks + render* methods)
│ ├── api.php # data/query functions used by hooks + render methods
│ └── routes.php # declares render handlers (for the manifest / admin sync)
├── frontend/
│ ├── widget.php # dashboard widget markup
│ ├── pages.php # full plugin page markup
│ └── settings.php # per-site settings form
├── tracking/
│ └── tracking.js # extension to cdn.ghoststat.me/gs.js
├── i18n/ # en.php, nl.php, de.php … (translations — PHP arrays)
├── assets/
│ └── css/ js/ icons/ images/
├── install.sql # plugin-owned tables, created on enable
└── uninstall.sql # dropped on uninstall
3. manifest.json
Only slug, name, version, scope are read into the registry columns; the
whole file is also stored as manifest_json and available to the app, so the
rest is yours to declare. The starter's manifest is a complete example. Fields:
| Field | Meaning |
|-------|---------|
| slug | Unique id, [a-z0-9-]. Names the folder, the DB row, and the route prefix. Required. |
| name, version, description, author | Shown in the admin marketplace. Bump version to cache-bust CDN assets. |
| scope | system (global) or user (per-account, gated by plan/group). |
| permissions | Capability strings your plugin needs (documented for the admin). |
| plans | Intended availability, e.g. { "free": false, "pro": true, "agent": true }. |
| routes | The render handlers you expose (see §5). |
| widgets, admin_pages | Declares the dashboard widget / admin page your hooks return. |
| hooks | The lifecycle/surface hooks you implement. |
| tracking | Your tracking.js path + which /collect/* endpoints it uses. |
| assets, i18n | Local CSS/JS/icon paths and the translation folder (i18n/, one .php file per language). |
4. The plugin instance + hooks
backend/logic.php defines a class and returns an instance of it. That
instance is what the loader keeps; every hook and route is a public method
on it. Hooks are called with a $ctx array (usually containing site, and for
ingestion session_id, visitor_hash, ts, event). Each hook is wrapped in
error handling by the core — a throw is logged, it won't crash the request.
flowchart TD
A[Admin enables plugin] -->|onEnable| B[install.sql runs · tables created]
B --> C[User enables for a site · saves config]
C --> D[Tracked site loads gs.js + tracking.js]
D -->|POST /collect/*| E[onEvent / onTracking · store rows]
C --> F[Dashboard]
F -->|onDashboardWidget → array| G[Widget in grid]
F -->|GET /plugin/slug/pages → renderPages| H[Full page]
C --> I[Public stats page] -->|onPublicStats → HTML| J[Injected HTML]
C --> K[Global stats gs.ghoststat.me] -->|onGlobalStats → array| L[Aggregates]
C --> M[Admin config] -->|onAdminPage → array| N[Settings panel]
A -->|onDisable / uninstall.sql| O[Teardown]
| Hook | Called when | Returns |
|------|-------------|---------|
| onEnable($ctx) | Plugin/site enabled | void — run install.sql here |
| onDisable($ctx) | Disabled | void (keep data; uninstall.sql runs on full uninstall) |
| onLoad($ctx) | Every active request | void — keep it cheap |
| onEvent($ctx) | Each incoming tracked event (/collect/*, after session stitching) | void — persist your rows |
| onTracking($ctx) | To inspect/annotate a payload before storage | the (possibly modified) $ctx |
| onDashboardWidget($ctx) | Building the dashboard | a widget array {id,title,size,html} |
| onPublicStats($ctx) | Rendering a site's public page | an HTML string (or '') |
| onGlobalStats($ctx) | Building gs.ghoststat.me | an array of aggregate rows |
| onAdminPage($ctx) | Building the admin GUI | an admin-page array {id,title,html} |
Global helpers you can call from anywhere in a plugin: db() (PDO),
Log::info/error(...), Security::audit(...), json_out(...), the view
helpers e() (escape) and asset() (versioned asset URL), and the translation
helper t('<prefix>.key') (server-side; client JS uses TR('<prefix>.key') / GS.t(...)).
5. Backend: routes & data
There's no router to wire. The core dispatches:
GET /plugin/{slug}/{handler} → render{Handler}() on your instance
So GET /plugin/example/pages calls renderPages($ctx), which returns an array
serialized to JSON. Keep the actual queries in backend/api.php as pure
"given a site, return data" functions (the starter's Example_stats()), so both
the widget and the full page reuse them. Read from the shared events /
sessions tables and from your own install.sql tables.
Sync rule: after adding a new plugin or a new render{Handler}
route, run Admin → Plugins → Rescan (POST /admin/plugins/sync). Editing
existing render methods or backend logic needs no sync.
6. Frontend: PHP templates + the design system
frontend/*.php are server-rendered fragments (the plugin captures them with
output buffering and returns the HTML). Use the GhostStat design system so your
plugin is indistinguishable from core:
- Bootstrap 5 grid + utilities, bootstrap-icons (
<i class="bi bi-…">).
- App classes:
gs-card / gs-card-body, gs-muted, gs-table /
gs-table-wrap, gs-tabs.
- Tokens are global via
variables.css — use var(--accent,#1FA463),
var(--ink), var(--line), the 6px card radius, the sh-1 shadow. Never
redefine them; scope your own rules under a .gs-plugin-<slug> class.
- Fully responsive, zero horizontal scroll on mobile, tables scroll inside
their container (gs-table-wrap), not the page.
- Escape everything user/data-derived with
e().
7. Tracking (tracking.js)
Your tracking.js is an extension to gs.js, not a standalone tracker. It
reuses gs.js's event queue, batching, SPA route detection, offline queue and
retry-with-backoff — you only describe what to send. Same hard rules: no
cookies, no localStorage/sessionStorage, no fingerprinting. Send via the
tracker's API (the starter uses gs.event(name, meta) and gs.on('route', …))
so data flows through the shared pipeline to /collect/event,
/collect/heatmap, or /collect/session. The server side lands in your
onEvent(). Confirm the exact gs.js method names against your build and
adjust the two calls in the starter.
Two JS files, two homes: tracking/tracking.js runs on tracked sites;
assets/js/plugin.js runs inside app.ghoststat.me (dashboard rendering).
Don't mix them.
8. SQL
install.sql creates your tables (run by onEnable); uninstall.sql drops
them. Conventions: prefix every table with your plugin (ex_…), always index
(site_id, ts) plus whatever you filter on (name, path, device_type,
utm_source), store flexible payloads in a JSON column rather than sprouting
columns, and use IF NOT EXISTS so a re-enable is safe. Plugin data stays in
plugin-owned tables — never alter core events/sessions.
9. Translations (i18n)
Layout: i18n/<code>.php — one PHP file per language that returns a flat
key => string array, with en.php as the source of truth. This is the same
format core GhostStat uses, so plugin strings resolve through the same helpers
as the rest of the app — there is no plugin-specific translation function, and no
assets/lang/*.json.
- The platform merges your file into the app's i18n map via
I18n::loadPlugin('<slug>') (the starter calls this in its constructor;
the core also loads it when your plugin's page or settings panel render).
- Resolve a string server-side with the global
t('<prefix>.key'), and
client-side (your assets/js/plugin.js) with TR('<prefix>.key') or
GS.t('<prefix>.key') — the browser gets the merged map as GS.i18n.
- Keys are namespaced by a short prefix, not by folder. Pick one for your
plugin (the starter uses ex.) and prefix every key — ex.widget_title,
ex.no_data — so they never collide with core or another plugin.
- A missing key falls back: chosen locale -> English (
en.php) -> the key
itself, so an untranslated string degrades gracefully instead of blank.
Declare the set in manifest.json:
"i18n": { "default": "en", "path": "i18n", "languages": ["en","nl","de","fr","es"] }
Workflow for the community (this is what your Plugin Translations forum tags
are for):
- Keep every visible string as a prefixed key in
i18n/en.php — never
hard-code UI text (server templates call t('ex.…'), client JS TR('ex.…')).
- A translator copies
i18n/en.php -> i18n/nl.php and translates the
values only, leaving the keys untouched.
- They post it under the matching language tag (Dutch/German/French/Spanish);
you drop it into i18n/, bump the plugin version, and redeploy.
- Reward it: an accepted, kept-current language pack is a Translator path to
Pro-for-life in the incentive system.
10. Deploy checklist
- Folder in
/public_html/plugins/<slug>/; mirror assets/ to
/public_html/cdn/plugins/<slug>/assets/ and keep them in sync (diff -q).
- Bump
manifest.json version — this is the asset cache-buster (?v=).
- Admin → Plugins → Rescan (only for a new plugin or new routes).
- Enable + grant to the right plans/groups.
- Test: enable for a site, confirm the widget renders, the page loads
(/plugin/<slug>/pages), tracked events land in onEvent, settings save.
- OPcache is disabled on the server — never add a cache-reset step.
- For production assets, the asset optimizer serves
.min.css/.min.js +
.gz/.br automatically when enabled; ship readable source and let it
minify.
11. Checklist when copying the starter
Rename all of these so nothing collides with example:
manifest.json → slug, name
ExamplePlugin class name (and the return new ExamplePlugin();)
Example_* functions in api.php
ex_ table prefix in install.sql / uninstall.sql / onEvent
.gs-plugin-example CSS scope
ex. key prefix in i18n/en.php + i18n/nl.php (and the t('ex.…') / TR('ex.…') calls)
example_* event names in tracking.js
That's a whole plugin: manifest → instance with hooks → data → templates →
tracking → SQL → translations, all matched to how GhostStat actually loads it.