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 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().
Hook priorities
Every pl_add_hook() call accepts an optional $priority argument (default: 10). Lower numbers run first. This matters when two plugins register the same hook and their order of execution is significant.
// Runs before the default priority (10)
pl_add_hook('early_request', function () {
my_plugin_do_something_first();
}, 5);
// Runs after the default priority
pl_add_hook('early_request', function () {
my_plugin_do_something_last();
}, 20);
The same priority system applies to pl_add_filter() below.
Filters
Filters let plugins transform a value that passes through a named pipeline. Unlike hooks (which fire a callback and discard the return value), every filter callback must return the value — modified or unchanged.
pl_add_filter(string $hook, callable $callback, int $priority = 10): void
Registers a filter callback on a named hook.
pl_apply_filter(string $hook, mixed $value, mixed ...$args): mixed
Passes $value through all callbacks registered on $hook in priority order and returns the final result. Extra $args are forwarded to every callback after $value. Returns $value unchanged if no filters are registered on that hook.
When to use filters
Filters are the right tool when your plugin wants to modify data that is produced by another plugin or by the core, without patching their code. The key constraint is that the code producing the value must explicitly call pl_apply_filter() at the point where it wants to allow modification — if it doesn't, there is nothing to hook into.
Example — modifying a value before it is used
// Plugin A produces a value and passes it through a filter point it owns:
$price = pl_apply_filter('my-plugin/item-price', $rawPrice, $item);
// Plugin B (or any other plugin) can modify that value:
pl_add_filter('my-plugin/item-price', function (float $price, array $item): float {
// Apply a 10% discount to items in the 'sale' category
if (($item['category'] ?? '') === 'sale') {
return $price * 0.90;
}
return $price;
});
Example — multiple filters in a pipeline
When several callbacks are registered on the same filter hook, they run in priority order and each receives the value as modified by the previous one:
pl_add_filter('my-plugin/item-price', function (float $price): float {
return $price * 0.90; // 10% discount
}, 10);
pl_add_filter('my-plugin/item-price', function (float $price): float {
return $price - 5.00; // additional €5 off, applied after the percentage
}, 20);
Naming filter hooks
Use a namespaced format — {your-plugin-slug}/{what-is-being-filtered} — to avoid collisions with other plugins:
'my-plugin/item-price'
'my-plugin/email-subject'
'my-plugin/rendered-html'
Plugin options API
The plugin options API provides a lightweight, centralized way to store and retrieve simple values — flags, counters, timestamps, configuration toggles — without writing your own JSON read/write boilerplate. It stores data in plugins/{slug}/data/options.json with an in-request memory cache and atomic writes.
When to use it
The options API is well-suited for simple, flat values that don't have complex defaults or nested structures:
- A boolean toggle (
'enabled','maintenance_mode') - A timestamp (
'last_digest_sent_at') - A counter (
'total_submissions') - A short string (
'from_email','webhook_url')
For plugins with structured settings (nested arrays, smtp configuration objects, per-type defaults), implementing your own load_settings() / save_settings() pair against a dedicated data/config.json is more appropriate — the options API stores values flat, key by key, and doesn't handle deeply nested defaults.
pl_get_option(string $slug, string $key, mixed $default = null): mixed
Returns the stored value for $key, or $default if the key has never been set.
$enabled = pl_get_option('my-plugin', 'enabled', false);
$lastRun = pl_get_option('my-plugin', 'last_run_at', null);
$email = pl_get_option('my-plugin', 'notify_email', '');
pl_set_option(string $slug, string $key, mixed $value): bool
Stores $value under $key and persists to disk. Returns true on success. The value must be JSON-serializable.
pl_set_option('my-plugin', 'enabled', true);
pl_set_option('my-plugin', 'last_run_at', date('Y-m-d H:i:s'));
pl_set_option('my-plugin', 'notify_email', 'admin@example.com');
pl_delete_option(string $slug, string $key): bool
Removes a single key from the options store. Returns true even if the key didn't exist.
pl_delete_option('my-plugin', 'last_run_at');
Practical example — a simple toggle plugin
// In my-plugin-init.php: register admin menu and read the option at request time
pl_add_hook('early_request', function () {
if (pl_get_option('my-plugin', 'maintenance', false)) {
http_response_code(503);
include __DIR__ . '/maintenance.html';
exit;
}
});
// In admin/actions.php: save the toggle when the admin submits the form
$enabled = !empty($_POST['maintenance']);
pl_set_option('my-plugin', 'maintenance', $enabled);
Storage details
Options are stored in plugins/{slug}/data/options.json. The data/ directory and its .htaccess protection are created automatically on first write — you do not need to call ensure_dirs() before using the options API. The file is read once per request and cached in memory; subsequent pl_get_option() calls within the same request read from cache, not from disk.
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 config.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);
$configFile = dirname(__DIR__, 2) . '/config.json'; // adjust depth to your file's location
$adminDir = 'admin';
if (file_exists($configFile)) {
$decoded = json_decode(file_get_contents($configFile), 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. Always rewrite the .htaccess unconditionally — do not skip the write with a file_exists() check, because a directory that was deleted and manually recreated would be left unprotected:
function my_plugin_ensure_dirs(): void
{
$dir = __DIR__ . '/data';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
// Always rewrite — never skip with file_exists()
file_put_contents($dir . '/.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 config.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
{
$configFile = dirname(__DIR__, 2) . '/config.json'; // adjust to your depth
if (file_exists($configFile)) {
$decoded = json_decode(file_get_contents($configFile), 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
(always rewrite .htaccess — never skip with file_exists())
[ ] 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
