Plugins
Create plugins as single PHP files in plugins/ or as subdirectories with a manifest.json and a bootstrap file. Contributions and distributed plugins are accepted under the terms of the CLA.md.
Plugin Conventions
- File-based plugin: a single PHP file in
plugins/ - Folder-based plugin: a subdirectory in
plugins/with amanifest.jsonand a bootstrap PHP file (e.g.,plugins/editbored/) - Folder-based plugins are required when the plugin needs extra assets (CSS, JS, images, lang files)
Plugin Metadata
Manifest v1 (folder-based plugins)
Folder-based plugins use manifest.json with the following schema:
{
"id": "editbored",
"name": "EditBored",
"version": "1.2.0",
"author": "mlzog",
"description": "WYSIWYG Markdown editor",
"core": ">=0.6",
"php": ">=8.1",
"dependencies": {
"other-plugin": ">=1.0.0"
},
"permissions": ["posts.edit"],
"routes": [],
"events": [],
"bootstrap": "editbored.php",
"files": [
"editbored.php",
"manifest.json",
"assets/css/editbored.css",
"assets/js/editbored.js",
"lang/en.json"
]
}
| Field | Required | Description |
|---|---|---|
id |
Yes | Stable identifier (lowercase alphanumeric + hyphens). Never changes even if name/author change. |
name |
Yes | Display name |
version |
Yes | Semver version string |
author |
No | Author name |
description |
No | Short description |
core |
No | Version constraint on the bulletinbored core (e.g. ">=0.6 <1.0.0") |
php |
No | PHP version constraint (e.g. ">=8.1") |
dependencies |
No | Object mapping plugin IDs to semver constraints. Supports >=, <=, >, <, ==, != (e.g. {"other-plugin": ">=1.0.0 <2.0.0"}) |
permissions |
No | Array of permission strings the plugin needs |
routes |
No | Array of custom route definitions |
events |
No | Array of event subscriptions (documentation only) |
bootstrap |
No | Bootstrap filename (defaults to <id>.php) |
files |
No | Array of files for integrity verification. When plugin_verify_files is enabled, files present but undeclared are rejected, and files declared but missing are rejected. Repo-only paths that GitHub source archives add automatically (tests/, .github/, .gitignore, composer.*, phpunit.xml*, .editorconfig, .travis.yml) are ignored by the check and stripped from the deployed package, so you do not list them. |
The manifest is validated at install time. Plugins with incompatible core/PHP versions, unmet dependencies, or manifest schema errors are rejected. Install and update run the same validation pipeline.
Legacy format (file-based plugins)
File-based plugins expose metadata via PHPDoc comments in the bootstrap file:
/**
* Plugin Name: MyPlugin
* Version: 1.0.0
* Author: Developer
* Description: Example plugin
*/
Folder-based plugins use manifest.json:
{
"name": "editbored",
"version": "1.0.0",
"author": "mlzog",
"description": "WYSIWYG Markdown editor",
"bootstrap": "editbored.php",
"files": [
"editbored.php",
"manifest.json",
"assets/css/editbored.css",
"assets/js/editbored.js",
"lang/en.json"
]
}
Hook System
Plugins register callbacks via $pluginManager->addHook('event', $callback, $priority).
Hook Types
| Method | Purpose | Returns |
|---|---|---|
addHook($event, $callback, $priority = 10) |
Register a callback (lower priority = earlier execution) | void |
runHook($event, ...$args) |
Fire an action hook (all callbacks execute) | void |
applyHook($event, ...$args) |
Return first non-null value from callbacks | mixed |
filter($event, $value, ...$args) |
Chain value through all callbacks (output → input) | mixed |
checkHook($event, ...$args) |
True if ANY callback returns true (veto pattern) | bool |
checkHookAll($event, ...$args) |
True only if ALL callbacks return true | bool |
removeHook($event, $callback) |
Unregister a callback | void |
Actions vs Filters vs Checks
- Actions (
runHook): Side effects only. All callbacks fire in priority order. - Filters (
filter): Transform a value. Each callback receives the accumulated value and returns a modified version. - Checks (
checkHook/checkHookAll): Permission/veto gates. Return true to allow/block.
Core Events
CRUD Hooks — Threads
| Event | Type | Arguments | When |
|---|---|---|---|
thread_before_create |
filter | $data (array) |
Before thread INSERT. Modify data before save. |
thread_after_create |
action | $threadId, $data |
After thread INSERT |
thread_create_block |
check | $data |
Return true to veto thread creation |
thread_before_update |
filter | $data, $thread |
Before thread UPDATE |
thread_after_update |
action | $threadId, $data, $thread |
After thread UPDATE |
thread_before_delete |
action | $threadId, $thread |
Before thread DELETE |
thread_after_delete |
action | $threadId, $thread |
After thread DELETE |
thread_delete_block |
check | $thread |
Return true to veto deletion |
CRUD Hooks — Posts
| Event | Type | Arguments | When |
|---|---|---|---|
post_before_create |
filter | $data, $thread |
Before post INSERT |
post_after_create |
action | $postId, $data, $thread |
After post INSERT |
post_create_block |
check | $data, $thread |
Return true to veto post creation |
post_before_update |
filter | $data, $post |
Before post UPDATE |
post_after_update |
action | $postId, $data, $post |
After post UPDATE |
post_before_delete |
action | $postId, $post |
Before post DELETE |
post_after_delete |
action | $postId, $threadId |
After post DELETE |
post_delete_block |
check | $post |
Return true to veto deletion |
Rendering Hooks
| Event | Type | Arguments | When |
|---|---|---|---|
thread_before_view |
filter | $thread |
Before thread data is rendered |
thread_posts_before_view |
filter | $posts, $thread |
Before posts array is rendered |
thread_before_render |
action | $thread, $posts |
Before template include |
thread_after_render |
action | $thread, $posts |
After template include |
thread_not_found |
apply | $threadId |
Custom 404 fallback. Return HTML to override. |
before_render |
action | — | Before any page render (head injection) |
frontend_before_render |
action | — | Before frontend page render |
admin_before_render |
action | — | Before admin page render |
admin_sidebar_items |
action | — | Inside the admin sidebar. Callbacks echo <li> entries to add menu items. |
footer_before_render |
action | — | Before footer render |
render_content |
filter | $text |
Post content rendering. Return HTML to override Markdown. |
Auth & Permission Hooks
| Event | Type | Arguments | When |
|---|---|---|---|
auth_before_verify |
filter | $user, $username, $password |
Before password verification |
auth_login_block |
check | $user |
Return true to block login |
auth_after_login |
action | $userId, $user |
After successful login |
auth_login_failed |
action | $username |
After failed login attempt |
permission_{name} |
check | $roleName |
Custom permission check (e.g. permission_can_ban_users) |
User Hooks
| Event | Type | Arguments | When |
|---|---|---|---|
user_registered |
action | $userId, $username |
After user registration |
CLI Hooks
| Event | Type | Arguments | When |
|---|---|---|---|
cli |
action | $registry (CommandRegistry) |
During CLI bootstrap, before command dispatch |
Plugins can register custom CLI commands:
function myplugin_init() {
global $pluginManager;
$pluginManager->addHook('cli', function($registry) {
$registry->register(
'myplugin:clear',
'Clear my plugin cache',
function($args) {
// Command logic
echo "Cache cleared!\n";
}
);
});
}
Usage: php bb.php myplugin:clear
Example: Block Thread Creation
function myplugin_init() {
global $pluginManager;
$pluginManager->addHook('thread_create_block', function(array $data): bool {
return strpos($data['title'] ?? '', 'spam') !== false;
});
}
Example: Filter Post Content Before Save
function myplugin_init() {
global $pluginManager;
$pluginManager->addHook('post_before_create', function(array $data, array $thread): array {
$data['content'] = str_replace('badword', '****', $data['content']);
return $data;
});
}
Example: Custom Permission
function myplugin_init() {
global $pluginManager;
// Grant 'can_ban_users' permission to a custom role
$pluginManager->addHook('permission_can_ban_users', function(string $roleName): bool {
return $roleName === 'super_mod';
});
}
Example: Add an Admin Sidebar Item
admin_sidebar_items is emitted once inside the Extensions group of the admin
sidebar. Echo an <li> (matching the existing sidebar markup) to add an entry.
The callback only runs for administrators, and only on admin pages.
function myplugin_init() {
global $pluginManager;
$pluginManager->addHook('admin_sidebar_items', function() {
if (!function_exists('is_admin') || !is_admin()) {
return;
}
$url = htmlspecialchars(base_url() . '/admin/my-plugin', ENT_QUOTES, 'UTF-8');
$active = str_contains($_SERVER['REQUEST_URI'] ?? '', '/admin/my-plugin') ? 'active' : '';
echo '<li><a href="' . $url . '" class="' . $active . '">'
. '<i class="fas fa-database"></i> <span>My Plugin</span></a></li>';
});
}
Pair it with a route registered in the same {name}_init() (see
Custom Routes and Middleware) so the link points
to a page the plugin actually serves.
Directory Structure
plugins/
├── hellobored/ # Example folder-based plugin
│ ├── manifest.json # Required: metadata
│ ├── hellobored.php # Required: bootstrap with hellobored_init()
│ ├── assets/
│ │ ├── css/
│ │ │ └── hellobored.css # Example styles
│ │ └── js/
│ │ └── hellobored.js # Example logic
│ ├── upload.php # Optional: custom endpoint
│ ├── lang/
│ │ └── en.php # Optional: translations
│ └── vendor/ # Optional: third-party assets
└── someplugin.php # Example file-based plugin
Shipping as ZIP
To distribute a plugin as a ZIP package:
- Package your plugin so that the plugin folder is at the root of the ZIP
- For file-based plugins, the PHP file should be at the root of the ZIP
- For folder-based plugins, the folder should be at the root of the ZIP
- Upload via Admin Panel → Plugins → Install Plugin
Recommended ZIP layout:
myplugin-1.0.0.zip
├── myplugin/
│ ├── manifest.json
│ ├── myplugin.php
│ └── assets/...
The installer automatically detects a single top-level folder and flattens it.
Managing Plugins
- Install: upload a ZIP or install from the catalog/repository. Both paths download into a staging directory and run the same validation — manifest plus core/PHP constraints and, when
plugin_verify_filesis enabled, thefilesintegrity check — before the package is committed, so a plugin gets identical guarantees whether it came from a ZIP or a Git repository. Reinstalling over an existing plugin is treated as an update: it runs the same hooks and rolls back files plus theinstalled.jsonrecord on failure. - Update: the Update Manager can apply new versions as ZIP packages. It delegates to
PluginManager::updateFromZip(), so the manual admin action and “Update All” run the exact same pipeline: the old folder is moved aside as a backup, the new ZIP is extracted and verified, lifecycle hooks (plugin_updated,<key>_on_update()) run, and the backup is removed only on success. On any failure the backup and the previousinstalled.jsonrecord are restored and the original is left untouched. - Enable / Disable:
enable()checks dependencies before activating.disable()cascades transitively to all plugins that depend on the disabled one (each is marked withauto_disabled_by). - Auto-cascade is one-way: if
A → B → Cand you disableC, bothAandBare auto-disabled. Re-enablingCdoes not automatically re-enableAorB. UseenableWithDeps($name)to walk the dependency chain back up — each plugin is enabled only if its own dependencies are satisfied. - Uninstall: disables the plugin, runs the uninstall lifecycle (see below), removes the installed folder, and clears metadata from
data/plugins.jsonanddata/installed.json. - Recovery: if a plugin fails to load at boot, it is automatically disabled. Use
getFailedPlugins()to list them andrecoverPlugin()to disable them manually.
Plugin Lifecycle
discovered → installed → enabled → disabled → enabled
↓ ↓
incompatible failed → disabled
↓
uninstall
A plugin failure never prevents the forum from booting — the failure is isolated and the plugin is disabled automatically.
Lifecycle events
The core fires these events around install, update, enable, disable, and uninstall:
| Event | Arguments | When |
|---|---|---|
plugin_installed |
$key, $manifest |
After a successful install |
plugin_updating |
$key, $entry |
Before an update starts (existing manifest passed) |
plugin_updated |
$key, $manifest |
After a successful update |
plugin_enabled |
$key |
After enable() succeeds |
plugin_disabled |
$key |
After disable() succeeds (cascade target is $key) |
plugin_auto_disabled |
$dependentKey, $causeKey |
When a plugin is auto-disabled by cascade |
plugin_uninstalling |
$key, $entry |
Before uninstall starts |
plugin_uninstalled |
$key |
After uninstall completes |
plugin_load_failed |
$key, \Throwable |
When <key>_init() throws |
Lifecycle functions
Plugins can opt in to install-time, update-time, and uninstall-time work by defining these functions in their bootstrap file. They are called inside a try/catch, so a failure is logged; whether it also aborts the operation depends on the hook:
on_install/on_update— a throw aborts the install/update and triggers the full rollback (previous files,installed.jsonand the backup are restored). Keep them safe and idempotent.on_uninstall/cleanup/migration_rollback— a throw is logged but does not block the uninstall.
| Function | When |
|---|---|
<key>_on_install() |
After a successful install (DB tables, default options, …) |
<key>_on_update() |
After a successful update (migrations, data backfill, …) |
<key>_on_uninstall() |
After the plugin has been disabled, before its files are deleted |
<key>_cleanup() |
Runs after files are removed (drop temp tables, clear caches) |
<key>_migration_rollback() |
Runs after cleanup() (revert DB migrations introduced by the plugin) |
Example: define on_uninstall to drop plugin-specific tables.
function myplugin_on_uninstall() {
$pdo = App::getInstance()->pdo;
$pdo->exec("DROP TABLE IF EXISTS myplugin_items");
}
function myplugin_migration_rollback() {
$pdo = App::getInstance()->pdo;
$pdo->exec("DELETE FROM schema_version WHERE plugin = 'myplugin'");
}
plugins/
└── myplugin/
├── manifest.json
├── myplugin.php
├── api.php
├── assets/
│ ├── css/
│ │ └── myplugin.css
│ └── js/
│ └── myplugin.js
└── lang/
└── en.json
Localization
Plugins can be localized independently from the core. Each plugin gets its own translation scope, so plugin strings never collide with the core or with other plugins (e.g. two plugins may both use a title key without conflict).
Folder-based plugins only (file-based plugins cannot carry lang files): place translation files under lang/ using the language code as filename:
plugins/myplugin/
└── lang/
├── en.json
└── it.json
Each file is a JSON object of string keys to string values:
{
"bold": "Bold",
"italic": "Italic"
}
The strings are loaded automatically into the plugin:<name> scope (e.g. plugin:myplugin) based on the active language, and are available before the plugin’s init hook runs.
Usage
Use the pt() helper, which is equivalent to t($key, $params, 'plugin:<name>'):
echo pt('myplugin', 'bold'); // -> 'Bold'
echo pt('myplugin', 'hello', ['name' => 'Joe']); // with {name} placeholder
You may also call the core translation function directly with an explicit scope:
echo t('bold', [], 'plugin:myplugin');
If a key is missing in the plugin’s language file, the key itself is returned (untranslated) — so a plugin that ships no lang/ directory still works unchanged. The core translation function t($key, $params) continues to resolve only from the core scope and is unaffected by plugin translations.
Content Security Policy (CSP)
The core sends a strict CSP header with a per-request nonce. Any inline <script> tag emitted by a plugin (in hook output, templates, or API responses) must carry this nonce or the browser will block it:
<script nonce="<?= htmlspecialchars(csp_nonce(), ENT_QUOTES, 'UTF-8') ?>">
// your inline code
</script>
The nonce is available on every request via csp_nonce(). External script files loaded from 'self' or the allowed CDN (cdn.jsdelivr.net) do not need the nonce. Embeds (YouTube, Twitter/X, Instagram, Facebook) are loaded in iframes and are
restricted by the CSP frame-src allow-list, not by script-src.
Avoid inline event handlers (onclick, onload, …); they are blocked by the CSP. Attach listeners from an external script or a nonce’d inline script instead.
Architecture
The plugin system is split across three classes:
| Class | File | Responsibility |
|---|---|---|
PluginManager |
lib/PluginManager.php |
Orchestration: hooks, enable/disable, lifecycle, settings, router |
PluginDiscovery |
lib/PluginDiscovery.php |
Filesystem scanning, metadata/manifest parsing |
PackageInstaller |
lib/PackageInstaller.php |
ZIP extraction, file verification, atomic install |
PluginManager delegates filesystem operations to PluginDiscovery and installation to PackageInstaller.
Plugin Manager API
// Discovery
$pluginManager->discover();
$pluginManager->getAll();
$pluginManager->getEnabled();
$pluginManager->getByName('myplugin');
// State
$pluginManager->isEnabled('myplugin');
$pluginManager->enable('myplugin'); // Checks dependencies before enabling. No cascade UP.
$pluginManager->disable('myplugin'); // Cascades transitively to all dependents
$pluginManager->enableWithDeps('myplugin'); // Walk dependency chain UP. Each plugin enabled only if its own deps are satisfied.
$pluginManager->getPluginState('myplugin'); // enabled, disabled, incompatible, corrupted, failed, not_found
// Dependencies
$pluginManager->checkDependencies('myplugin'); // ['compatible' => bool, 'reason' => '...'] — applies full semver
$pluginManager->detectCycle('myplugin'); // Returns cycle path or null
$pluginManager->getDependents('myplugin'); // Plugins that transitively depend on this one (memoized)
// Settings
$pluginManager->getSetting('myplugin', 'key', $default);
$pluginManager->setSetting('myplugin', 'key', $value);
// Manifest validation
$pluginManager->validateManifest($manifest); // ['valid' => bool, 'errors' => [...]]
$pluginManager->normalizeManifest($manifest); // Derive 'id' from 'name'
// Localization
$pluginManager->loadTranslations($lang);
// Lifecycle
$pluginManager->loadEnabled();
$pluginManager->installFromRepo('https://github.com/user/repo', 'v1.0.0'); // Staged + validated like installFromZip; reinstalls run on_update
$pluginManager->installFromZip('/path/to/plugin.zip'); // Fresh install via ZIP. Detects name from manifest.
$pluginManager->installFromZip('/path/to/v2.zip', 'myplugin', true); // Update in place (replaces existing folder)
$pluginManager->updateFromZip('myplugin', '/path/to/v2.zip'); // Convenience wrapper for update
$pluginManager->uninstall('myplugin'); // disable → on_uninstall → remove files → cleanup → migration_rollback
$pluginManager->recoverPlugin('myplugin'); // Disable a failed plugin
// Failure recovery
$pluginManager->getFailedPlugins(); // List plugins that failed to load
// Versioning
$pluginManager->getVersion('myplugin');
// Hooks
$pluginManager->addHook('event', $callback);
$pluginManager->removeHook('event', $callback);
$pluginManager->runHook('event', ...$args);
$pluginManager->applyHook('event', ...$args);
$pluginManager->filter('event', $value, ...$args);
$pluginManager->checkHook('event', ...$args);
$pluginManager->checkHookAll('event', ...$args);
// Router
$pluginManager->setRouter($router);
$pluginManager->getRouter();
$pluginManager->registerRoute('GET', '/my-plugin/endpoint', $handler);
$pluginManager->registerMiddleware('my_mw', $fn);
$pluginManager->applyRoutes();
Custom Routes and Middleware
Plugins can register custom routes and middleware during their {name}_init() function. These are applied before dispatch:
function myplugin_init() {
global $pluginManager;
$router = $pluginManager->getRouter();
if (!$router) return;
// Register a custom route
$pluginManager->registerRoute('GET', '/my-plugin/api', function() {
header('Content-Type: application/json');
echo json_encode(['status' => 'ok']);
return ['status' => 200, 'body' => ''];
});
// Register a custom middleware
$pluginManager->registerMiddleware('my_plugin_auth', function($params) {
if (!some_check()) {
return ['status' => 403, 'body' => 'Forbidden'];
}
return null;
});
}
Route handlers receive $params (array of matched route parameters) and should return null to continue, or ['status' => int, 'body' => string] to short-circuit the response. Optional headers array can be added for redirects: ['status' => 302, 'body' => '', 'headers' => ['Location: /target']].
Third-party plugins
The catalog mixes plugins developed by the bulletinbored team (first-party) and plugins developed by community contributors (third-party). The catalog UI labels each one so you can tell them apart. Every catalog entry is reviewed by the bulletinbored team before being added to the catalog.
Even so, the bulletinbored team does not assume any responsibility for the code, security, or behavior of third-party plugins. Installing one remains an act of trust in its author. If a plugin is later found to be malicious, bulletinbored will remove it from the catalog as soon as it is reported by a user. Install and use them at your own risk.
If you encounter a malicious or problematic plugin, please report it on the official forum: www.bulletinbored.net/forum.