⌘K

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"
}
FieldRequiredDescription
synaptik_pluginYesMust be true — this is how the CMS distinguishes a valid plugin folder from anything else placed under /plugins/.
slugYesStable 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.
entryYesPath to the PHP file to require_once, relative to the plugin's own folder.
name, description, author, versionNoDisplay 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_menuindex.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]') === false is 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 use
  • private/ — created automatically, contains secrets that should never ship pre-populated
  • .DS_Store and 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