View .md

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:

PluginShows
demoshowcaseClient, 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, demowelcomeThe smallest valid plugin: conf.yml and an entry class with empty hooks.

Frequently asked questions

Where can I see a real plugin with widgets and pages?

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.

How often does a plugin cron job run?

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.