Plugin System
Plugins let you extend SynaptikCMS with self-contained functionality — booking systems, storefronts, custom integrations — without ever modifying core files. A plugin owns its own routing, data storage, and admin screens, and integrates into the real admin panel (sidebar entry, full page inside the standard layout) once activated.
How plugins work
A plugin is a folder under /plugins/ containing at minimum a plugin.json manifest and an entry file:
plugins/my-plugin/
├── plugin.json ← REQUIRED — manifest
├── my-plugin-init.php ← REQUIRED — entry point, loaded when active
├── admin/
│ └── admin-page.php ← exposes the admin page renderer (optional)
├── assets/
│ ├── css/
│ └── js/
├── lang/ ← front-end translations
│ └── admin/ ← admin translations
├── data/ ← the plugin's own data store (create at runtime)
└── private/ ← secrets, rate-limit stores (create at runtime, .htaccess-protected)
Only plugin.json and the entry file it points to are required. Everything else — admin integration, assets, i18n, data storage — is opt-in and built by the plugin itself using the patterns below.
Activation registry
Installing a plugin (copying its folder into /plugins/, or uploading a ZIP from Admin → Tools → Extensions) does not activate it. Activation state lives in plugins.json at the CMS root:
{
"my-plugin": { "active": true }
}
A plugin only runs — its entry file is only loaded — once activated from the Extensions page. Deactivating a plugin stops it from loading on the next request but preserves its data; deleting a plugin is only allowed while inactive.
plugin.json manifest
{
"synaptik_plugin": true,
"name": "My Plugin",
"slug": "my-plugin",
"version": "1.0.0",
"description": "One-line description shown on the Extensions page.",
"author": "Your Name",
"entry": "my-plugin-init.php"
}
| Field | Required | Description |
|---|---|---|
synaptik_plugin | Yes | Must be true — this is how the CMS distinguishes a valid plugin folder from anything else placed under /plugins/. |
slug | Yes | Stable identifier. Used as the folder name convention, the activation registry key, and the admin route parameter. Once published, treat this as permanent — changing it later is effectively a new plugin as far as the CMS is concerned. |
entry | Yes | Path to the PHP file to require_once, relative to the plugin's own folder. |
name, description, author, version | No | Display metadata shown on the Extensions page. |
The plugin API (plugin-api.php)
The CMS core exposes a small set of functions your plugin's entry file can call. These are always available by the time your entry file runs.
Registering an admin sidebar entry
if (function_exists('pl_on_admin_menu')) {
pl_on_admin_menu(function () {
pl_register_admin_menu(
'my-plugin', // slug
'My Plugin', // label (already translated)
my_plugin_admin_url('overview'), // URL — see "Building admin URLs" below
'<path d="..."/>' // inline SVG path data (Lucide-style, 24x24 viewBox)
);
});
}
pl_on_admin_menu() registers a callback fired once per admin page load, whenever the sidebar needs to know which plugins have a menu entry. It is safe to call unconditionally from your entry file — on front-end requests this hook is simply never fired, so the callback never runs there.
Activation and deactivation hooks
pl_add_hook('plugin_activate_my-plugin', function () {
// Runs once, immediately, when the plugin is activated from Extensions.
// Good place to create your data directory, write a default settings file, etc.
});
pl_add_hook('plugin_deactivate_my-plugin', function () {
// Runs once when deactivated. Deactivation should be non-destructive —
// preserve data so re-activating restores the previous state.
});
Checking whether your own plugin is active
if (pl_is_active('my-plugin')) {
// ...
}
Rarely needed from inside your own plugin (if your code is running, you're active by definition) but useful if one plugin wants to check for another.
Intercepting the front-end request before rendering starts
Two generic hooks let a plugin act on a front-end request before the site has done any rendering work — without index.php ever needing to know your plugin's name. This is the right tool when your plugin needs to block the whole page (a maintenance-mode screen) or needs to know the outcome of routing before the theme starts rendering (a redirect manager). For anything that runs during template rendering instead (injecting a footer script, resolving a shortcode), use add_theme_action() as described in "Front-end integration" below — these two hooks exist specifically for the two moments upstream of that.
early_request — fires first, before anything else loads
if (function_exists('pl_add_hook')) {
pl_add_hook('early_request', function () {
// Runs right after functions.php loads, before the data layer,
// routing, or any output. Exit early here and the site never pays
// the cost of loading content for this request.
if (my_plugin_should_block()) {
http_response_code(503);
echo 'Come back later.';
exit;
}
});
}
No arguments are passed to this hook. Register it unconditionally from your plugin's entry file — pl_add_hook() is always available by the time an active plugin's entry file loads.
after_routing — fires once the current route is known
if (function_exists('pl_add_hook')) {
pl_add_hook('after_routing', function ($isGenuine404) {
// Runs right after parseRequestUri() resolves the route, before any
// HTTP header or HTML output. $isGenuine404 is true when the core
// could not resolve the request to any known type/slug/category/tag.
if ($isGenuine404) {
// e.g. look up a manual redirect for the requested path
}
});
}
Use this when your plugin's behavior depends on knowing whether the current request is a genuine 404 (a redirect manager deciding whether to send visitors to the homepage, for example). It fires too early for $GLOBALS['data'] to hold anything but the lightweight index, and too late to block the request the way early_request does — by this point routing is resolved but no output has started, so header('Location: ...'); exit; still works cleanly.
Why these exist as core hooks instead of add_theme_action()
add_theme_action()'s hook points (before_content, footer_scripts, etc.) all fire during template rendering — by design, since that system exists to let plugins and themes inject markup into a page that's already being built. early_request and after_routing cover the two moments before that: nothing has been decided yet about what page to render, or whether to render one at all. A plugin needing one of these two moments registers a callback the same way it would for admin_menu — index.php calls pl_do_hook('early_request') and pl_do_hook('after_routing', $isGenuine404) at the right points without ever naming a specific plugin, so any plugin needing this level of access stays exactly as self-contained as one that only uses admin_menu or add_theme_action().
Rendering a page inside the admin panel
Plugins do not implement their own admin layout. Instead, the core's admin/index.php?action=plugin_page router renders your plugin's content inside the standard admin chrome — same sidebar, top bar, and footer as every built-in admin screen.
The contract
Define a function named {slug}_render_admin_page, with any character in your slug outside [a-z0-9_] replaced with _. For a plugin with slug my-plugin, that's:
function my_plugin_render_admin_page(string $view): array
{
// $view comes from ?view=... in the URL — your plugin defines what
// views exist (e.g. 'overview', 'settings') and what to render for each.
$html = '<p>Hello from My Plugin, view: ' . htmlspecialchars($view) . '</p>';
return [
'title' => 'My Plugin', // shown in <title> and the admin topbar
'html' => $html, // your page body — already-escaped, ready to print
'extra_head' => '', // optional <link>/<style>/<script> tags for <head>
];
}
The core router (admin/index.php) handles everything else: session/auth check, loading your plugin's entry file if it isn't already loaded, calling your render function, and wrapping the result in includes/layout.php.
Building admin URLs
Your plugin's admin page lives at:
{cms_base_url}/admin/index.php?action=plugin_page&slug=my-plugin&view={view}
Build this URL from your own filesystem position rather than hardcoding a path — the CMS may be installed at the domain root or in a sub-directory, and the admin folder name is configurable during installation (admin_dir in settings.json). A helper along these lines, adapted from the Booking plugin, is the reference pattern:
function my_plugin_admin_url(string $view, array $extraParams = []): string
{
$params = array_merge(['action' => 'plugin_page', 'slug' => 'my-plugin', 'view' => $view], $extraParams);
$settingsFile = dirname(__DIR__, 2) . '/settings.json'; // adjust depth to your file's location
$adminDir = 'admin';
if (file_exists($settingsFile)) {
$decoded = json_decode(file_get_contents($settingsFile), true);
if (is_array($decoded) && !empty($decoded['admin_dir'])) {
$adminDir = $decoded['admin_dir'];
}
}
return site_base_url() . $adminDir . '/index.php?' . http_build_query($params);
}
Always build this as an absolute URL (full https://host/path/...), not a path relative to the current script. Your admin page's own view templates, and any return_url fields in forms that POST to your plugin's own endpoints, all need to resolve correctly regardless of which physical file the browser is currently on — a relative path breaks the moment your plugin's action handler lives in a different folder than /admin/.
Front-end integration
The core's content-rendering pipeline (render_content_html()) has no filter hook a plugin can register a new shortcode into — extending it would require editing core files. Instead, plugins that need to inject content into front-end pages use output buffering: wrap the entire page response, find-and-replace your own marker (e.g. a custom shortcode string), and let everything else pass through untouched.
$isCli = PHP_SAPI === 'cli';
$isAdmin = strpos($_SERVER['REQUEST_URI'] ?? '', '/admin/') !== false;
if (!$isCli && !$isAdmin) {
ob_start(function (string $html): string {
if (strpos($html, '[my_shortcode]') === false) {
return $html; // nothing to do — page passes through unmodified
}
$html = str_replace('[my_shortcode]', my_plugin_render_widget_html(), $html);
// Inject CSS/JS only on pages that actually use the shortcode.
$assets = my_plugin_render_assets();
return stripos($html, '</head>') !== false
? preg_replace('/<\/head>/i', $assets . '</head>', $html, 1)
: $assets . $html;
});
}
A few things worth knowing before you build on this pattern:
- Never nest a second output buffer inside the callback. If a function called from inside the buffer's display handler tries
ob_start()/ob_get_clean()itself, PHP throws a fatal error ("Cannot use output buffering in output buffering display handlers"). Build your widget HTML with plain string concatenation or heredoc instead. - Guard against admin and CLI contexts. The buffer should only ever wrap front-end HTML responses — never admin pages (which have their own rendering path, see above) and never CLI/cron contexts.
- Check for your marker before doing any work.
strpos($html, '[my_shortcode]') === falseis a cheap early-out — the vast majority of page loads on a real site won't contain your shortcode, and every one of those pages should pay effectively zero cost for your plugin being active.
Public-facing endpoints
Any PHP file inside your plugin's folder is reachable directly by URL (e.g. /plugins/my-plugin/my-plugin.php). Use this for AJAX endpoints, form submission handlers, or anything else the front-end JavaScript needs to talk to. These files do not go through index.php's routing — they are self-contained scripts that load only what they need:
<?php
require_once __DIR__ . '/my-plugin-functions.php';
header('Content-Type: application/json; charset=UTF-8');
header('Cache-Control: no-store');
$action = $_GET['action'] ?? '';
// ... handle $action, echo json_encode(...), exit;
Protect internal files that should never be requested directly (settings loaders, security helpers, anything not meant to be a public entry point) with a .htaccess in your plugin's root:
<FilesMatch "\.(json)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Deny from all
</IfModule>
</FilesMatch>
Data storage
Plugins should store their own data under their own folder — never write into the CMS core's /data/ directory. The conventional layout, mirroring the core's own split-file pattern:
plugins/my-plugin/data/ ← your JSON data files
plugins/my-plugin/private/ ← secrets (CSRF keys, rate-limit stores) — .htaccess-protected
Both directories should be created on demand (not shipped in your plugin's distributed ZIP) with .htaccess protection written alongside them:
function my_plugin_ensure_dirs(): void
{
$dir = __DIR__ . '/data';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$htaccess = $dir . '/.htaccess';
if (!file_exists($htaccess)) {
file_put_contents($htaccess,
"<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n" .
"<IfModule !mod_authz_core.c>\n Deny from all\n</IfModule>\n"
);
}
}
Use atomic writes (write to a .tmp file, then rename()) for anything written outside of install-time setup, to avoid corrupting a file if the PHP process is interrupted mid-write:
function my_plugin_write_json(string $path, array $data): bool
{
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) return false;
$tmp = $path . '.tmp';
if (file_put_contents($tmp, $json, LOCK_EX) === false) return false;
return rename($tmp, $path);
}
Session and authentication
Do not build a separate login system for your plugin's admin screens. Reuse the CMS's own admin session:
function my_plugin_admin_is_logged_in(): bool
{
return isset($_SESSION['admin']) && $_SESSION['admin'] === true;
}
Anyone already authenticated in the CMS admin panel is authenticated for your plugin's admin actions too, and logging out of one logs out of both — it's the same PHP session. If your plugin has a standalone POST endpoint (e.g. admin/actions.php) that doesn't go through the ?action=plugin_page router, start the session yourself with the same cookie hardening the core uses:
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params([
'httponly' => true,
'samesite' => 'Lax',
'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
]);
session_start();
}
CSRF protection
Any state-changing action (form submission, admin action) should carry its own CSRF token — do not rely on the core's $_SESSION['csrf_token'] alone if your plugin has public-facing forms, since those need to work for anonymous visitors, not just logged-in admins. A stateless HMAC-based token works well for public forms:
function my_plugin_generate_csrf(): string
{
$secret = my_plugin_get_secret(); // random bytes, generated once, stored in private/
$timestamp = time();
$signature = hash_hmac('sha256', (string)$timestamp, $secret);
return base64_encode($timestamp . '|' . $signature);
}
function my_plugin_verify_csrf(string $token, int $ttlSeconds = 7200): bool
{
$decoded = base64_decode($token, true);
if ($decoded === false) return false;
[$timestamp, $signature] = array_pad(explode('|', $decoded, 2), 2, '');
if ($timestamp === '' || $signature === '') return false;
if ((time() - (int)$timestamp) > $ttlSeconds) return false;
$expected = hash_hmac('sha256', $timestamp, my_plugin_get_secret());
return hash_equals($expected, $signature);
}
For admin-only actions (already behind the login check), reusing $_SESSION['csrf_token'] — the same token the core admin panel itself uses — is simpler and sufficient.
Internationalisation
Plugins are expected to follow the CMS's active_language (front-end) and admin_language (admin panel) settings automatically, without requiring separate configuration. Read settings.json directly rather than depending on the core's __t() being loaded in every context your plugin runs in (public endpoints, for instance, don't load the full front-end bootstrap):
function my_plugin_current_locale(string $context = 'front'): string
{
$settingsFile = dirname(__DIR__, 2) . '/settings.json'; // adjust to your depth
if (file_exists($settingsFile)) {
$decoded = json_decode(file_get_contents($settingsFile), true);
if (is_array($decoded)) {
if ($context === 'admin' && !empty($decoded['admin_language'])) {
return $decoded['admin_language'];
}
if (!empty($decoded['active_language'])) {
return $decoded['active_language'];
}
}
}
return 'en';
}
function my_plugin_t(string $key, string $fallback = '', string $context = 'front'): string
{
static $cache = [];
$locale = my_plugin_current_locale($context);
if (!isset($cache[$context][$locale])) {
$path = __DIR__ . '/lang/' . ($context === 'admin' ? 'admin/' : '') . $locale . '.json';
$cache[$context][$locale] = file_exists($path)
? (json_decode(file_get_contents($path), true) ?: [])
: [];
}
return $cache[$context][$locale][$key] ?? $fallback;
}
Ship translations for at least English, French, and Spanish (lang/en.json, lang/fr.json, lang/es.json for the front-end; lang/admin/en.json etc. for admin strings) to match what the core itself ships with — never hardcode a user-visible string.
Distributing a plugin
A plugin is distributed as a .zip file, installed from Admin → Tools → Extensions using the upload form. The ZIP must contain plugin.json at its root (or inside a single top-level folder — both are supported):
my-plugin.zip
└── plugin.json
└── my-plugin-init.php
└── ...
Exclude anything generated at runtime from the distributed ZIP:
data/— created automatically on first useprivate/— created automatically, contains secrets that should never ship pre-populated.DS_Storeand other OS/editor artifacts
Uploading a ZIP extracts the plugin but does not activate it — activation is always a separate, explicit step, so an admin reviewing a newly-installed plugin has a chance to configure it before it starts handling real requests.
Checklist for a new plugin
[ ] plugin.json at the plugin's root, with synaptik_plugin: true and a stable slug
[ ] Entry file loads cleanly when the plugin is activated — no fatal errors,
no assumptions about which script triggered the load (admin vs front-end vs CLI)
[ ] Admin sidebar entry registered via pl_on_admin_menu(), if the plugin has an admin UI
[ ] Admin page rendered via {slug}_render_admin_page(), not a standalone layout
[ ] early_request / after_routing hooks used (not a core-file patch) if the
plugin needs to intercept a front-end request before rendering starts
[ ] Own data/ and private/ directories, created on demand with .htaccess protection
[ ] No writes to the CMS core's /data/ or /admin/ directories
[ ] CSRF protection on every state-changing action
[ ] Session reuse ($_SESSION['admin']) rather than a separate login system
[ ] Translations for en/fr/es, front-end and admin, following active_language / admin_language
[ ] All URLs built as absolute paths derived from the plugin's own filesystem
position — never hardcoded, never relative to the current script
[ ] data/ and private/ excluded from the distributed ZIP
