# 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.

Source: https://featherpanel.com/docs/plugins/examples/

## 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`:

```yaml
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
<?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`:

```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
<?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
<?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`:

```json
{
  "server": {
    "/notes": {
      "name": "Notes",
      "lucideIcon": "notebook-pen",
      "component": "notes.html",
      "category": "server",
      "group": "management",
      "priority": 350
    }
  }
}
```

`Frontend/Components/notes.html`:

```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](https://featherpanel.com/docs/plugins/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
<?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:

```js
(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

- [Plugin structure](https://featherpanel.com/docs/plugins/getting-started/)
- [Routes and permissions](https://featherpanel.com/docs/plugins/routes-and-permissions/)
- [Frontend and Power SDK](https://featherpanel.com/docs/plugins/frontend/)

## 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.
