Plugin examples
A complete FeatherPanel plugin built step by step (manifest, migration, model, server route, sidebar page), plus a cron job and an action handler.
On this page
A server notes plugin
This plugin lets users keep a note per server. It uses each part of a plugin once: a manifest, a migration, a model, a server route with a subuser permission check, and a page in the server sidebar.
1. Manifest
storage/addons/servernotes/conf.yml:
plugin:
name: ServerNotes
identifier: servernotes
description: 'A note per server'
flags:
- hasEvents
version: 1.0.0
target: v2
author:
- Your Name
icon: 'https://example.com/notes.png'
requiredConfigs: []
dependencies: []
config: []
2. Entry class
ServerNotes.php:
<?php
namespace App\Addons\servernotes;
use App\Plugins\AppPlugin;
use App\Plugins\PluginEvents;
class ServerNotes implements AppPlugin
{
public static function processEvents(PluginEvents $event): void
{
}
public static function pluginInstall(): void
{
}
public static function pluginUninstall(): void
{
$pdo = \App\App::getInstance(true)->getDatabase()->getPdo();
$pdo->exec('DROP TABLE IF EXISTS `featherpanel_servernotes_notes`');
}
}
3. Migration
Migrations/2026-01-01-12.00-create-servernotes-notes.sql:
CREATE TABLE IF NOT EXISTS `featherpanel_servernotes_notes` (
`server_id` INT NOT NULL,
`body` TEXT NOT NULL,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`server_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci;
4. Model
Chat/Note.php:
<?php
namespace App\Addons\servernotes\Chat;
use App\App;
class Note
{
private static string $table = 'featherpanel_servernotes_notes';
public static function get(int $serverId): string
{
$pdo = App::getInstance(true)->getDatabase()->getPdo();
$stmt = $pdo->prepare('SELECT body FROM ' . self::$table . ' WHERE server_id = :id');
$stmt->execute(['id' => $serverId]);
return (string) ($stmt->fetchColumn() ?: '');
}
public static function save(int $serverId, string $body): void
{
$pdo = App::getInstance(true)->getDatabase()->getPdo();
$stmt = $pdo->prepare(
'INSERT INTO ' . self::$table . ' (server_id, body) VALUES (:id, :body)
ON DUPLICATE KEY UPDATE body = VALUES(body)'
);
$stmt->execute(['id' => $serverId, 'body' => $body]);
}
}
5. Routes
Routes/servernotes.php:
<?php
use App\App;
use App\Chat\Server;
use App\Helpers\ApiResponse;
use App\Helpers\SubuserPermissionChecker;
use App\SubuserPermissions;
use App\Addons\servernotes\Chat\Note;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\RouteCollection;
return function (RouteCollection $routes): void {
App::getInstance(true)->registerServerRoute(
$routes,
'servernotes-note',
'/api/user/servers/{uuidShort}/servernotes',
function (Request $request, array $args) {
$server = Server::getServerByUuidShort($args['uuidShort'] ?? '');
if (!$server) {
return ApiResponse::error('Server not found', 'SERVER_NOT_FOUND', 404);
}
if ($request->getMethod() === 'GET') {
return ApiResponse::success(['body' => Note::get((int) $server['id'])]);
}
$user = $request->attributes->get('user');
if (!SubuserPermissionChecker::hasPermission($user['id'], $server['id'], SubuserPermissions::STARTUP_UPDATE)) {
return ApiResponse::error('Insufficient permissions', 'FORBIDDEN', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
Note::save((int) $server['id'], mb_substr((string) ($data['body'] ?? ''), 0, 10000));
return ApiResponse::success(null, 'Saved');
},
'uuidShort',
['GET', 'PUT']
);
};
Anyone with access to the server can read the note. Changing it needs the startup.update subuser permission,
which the owner and admins always have.
6. Sidebar page
Frontend/sidebar.json:
{
"server": {
"/notes": {
"name": "Notes",
"lucideIcon": "notebook-pen",
"component": "notes.html",
"category": "server",
"group": "management",
"priority": 350
}
}
}
Frontend/Components/notes.html:
<!doctype html>
<html lang="en">
<body>
<textarea id="note" rows="12" style="width:100%"></textarea>
<button id="save">Save</button>
<script>
const server = new URLSearchParams(location.search).get('serverUuid');
const url = `/api/user/servers/${server}/servernotes`;
const box = document.getElementById('note');
fetch(url).then((r) => r.json()).then((r) => { box.value = r.data?.body ?? ''; });
document.getElementById('save').addEventListener('click', async () => {
const r = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: box.value }),
}).then((res) => res.json());
window.parent.postMessage(
{ type: 'featherpanel-bus', op: 'emit', event: 'servernotes:saved', payload: { ok: r.success } },
location.origin
);
});
</script>
</body>
</html>
The page opens at /server/<uuidShort>/notes. The panel adds serverUuid to the iframe URL, and because the iframe
is on the panel’s origin the fetch calls carry the user’s session.
7. Install
Copy the folder to storage/addons/servernotes, run php cli migrate, and use Resync on the plugin page so
Frontend/Components is linked. Or export it as an .fpa and install that, see
Packaging and publishing.
A cron job
Cron/NotesCleanup.php. The cron runner loads every file in Cron/ and calls run() on the class
App\Addons\<identifier>\Cron\<file name>. App\Cron\Cron keeps track of the last run, so the work only happens
once per interval (30M, 1H, 1D, 1W and so on).
<?php
namespace App\Addons\servernotes\Cron;
use App\App;
use App\Cron\Cron;
use App\Cron\TimeTask;
use App\Chat\TimedTask;
class NotesCleanup implements TimeTask
{
public function run()
{
$cron = new Cron('servernotes-cleanup', '1D');
try {
$cron->runIfDue(function () {
$pdo = App::getInstance(true)->getDatabase()->getPdo();
$pdo->exec("DELETE FROM featherpanel_servernotes_notes WHERE body = ''");
TimedTask::markRun('servernotes-cleanup', true, 'Removed empty notes');
});
} catch (\Exception $e) {
TimedTask::markRun('servernotes-cleanup', false, $e->getMessage());
}
}
}
TimedTask::markRun records the result; the admin dashboard lists the most recent cron task runs.
An action handler
Frontend/index.js runs in the panel window. This handler asks for confirmation before a server is killed and
cancels the action if the user says no:
(function () {
function boot() {
const FP = window.FeatherPanel;
if (!FP || !FP.actions) return setTimeout(boot, 50);
FP.actions.register('fp:server:power', {
id: 'servernotes.confirm-kill',
priority: 100,
handler: async (ctx, next) => {
if (ctx.action === 'kill' && !window.confirm('Kill the server?')) {
ctx.cancel('user-aborted');
return;
}
await next();
},
});
FP.shortcuts.register({
id: 'servernotes-toast',
combo: 'ctrl+shift+n',
description: 'Show a toast',
handler: () => FP.toast.info('Hello from ServerNotes'),
});
}
boot();
})();
Demo plugins in the panel repository
deploy/demo/example-plugins/ in the FeatherPanel repository has four plugins used on the public demo:
| Plugin | Shows |
|---|---|
demoshowcase | Client, admin and server pages, five widgets (dashboard banner and card, admin home, server console), settings in config: that hide widgets through hidden rules, and a server page limited to certain spells. |
demostatus, demotools, demowelcome | The smallest valid plugin: conf.yml and an entry class with empty hooks. |
Related
Frequently asked questions
The panel repository ships demo plugins in deploy/demo/example-plugins. demoshowcase has client, admin and server pages, five widgets and settings that hide them.
The cron runner calls run() on every pass, once a minute. Wrap your work in App\Cron\Cron with an interval such as 1H to run it less often.