View .md

Build your first plugin

Write a small FeatherPanel plugin from scratch with a manifest, an entry class, an API route, a setting and a dashboard page, then load it in a running panel.

On this page

This page builds a plugin called helloworld from an empty folder. When you are done it has:

  • a conf.yml manifest with one setting,
  • an entry class,
  • an API route that only signed-in users can call,
  • a page in the user dashboard sidebar that calls that route.

Every API used here is covered in more detail in the rest of the plugin docs. If you would rather start from generated files, the plugin manager can create a plugin from a template, see Plugin structure.

Before you start

  • A running panel you can change files on. See Installation.
  • An admin account.
  • Access to backend/storage/addons/. In the Docker setup that is /var/www/html/storage/addons inside the featherpanel_backend container.

1. Create the folder

The folder name, plugin.identifier and the PHP namespace must all be the same. Identifiers may only contain letters, digits and _, so hello-world would be rejected.

storage/addons/helloworld/
├── conf.yml
├── HelloWorld.php
├── Routes/
│   └── api.php
└── Frontend/
    ├── sidebar.json
    └── Components/
        └── index.html

2. Write conf.yml

plugin:
  name: HelloWorld
  identifier: helloworld
  description: 'Greets the signed-in user'
  flags:
    - hasEvents
  version: 1.0.0
  target: v2
  author:
    - Your Name
  icon: 'https://example.com/icon.png'
  requiredConfigs: []
  dependencies: []
config:
  - name: greeting
    display_name: 'Greeting'
    type: text
    description: 'Text shown before the username.'
    required: false
    validation: {}
    default: 'Hello'

Every key under plugin: except the version limits and plugin_cloud_id is required, even when the value is an empty list. A missing key makes the panel log Invalid config for plugin: helloworld and skip the plugin. The manifest reference lists each key.

3. Write the entry class

The file name and class name are plugin.name, and the namespace is App\Addons\<identifier>:

<?php

namespace App\Addons\helloworld;

use App\Plugins\AppPlugin;
use App\Plugins\PluginEvents;
use App\Plugins\PluginSettings;

class HelloWorld implements AppPlugin
{
    public static function processEvents(PluginEvents $event): void
    {
        // No listeners yet. See Lifecycle and events.
    }

    public static function pluginInstall(): void
    {
        // The config default is only a placeholder in the settings form, so store a real value.
        if (PluginSettings::getSetting('helloworld', 'greeting') === null) {
            PluginSettings::setSetting('helloworld', 'greeting', 'Hello');
        }
    }

    public static function pluginUninstall(): void
    {
        PluginSettings::deleteSettings('helloworld', 'greeting');
    }
}

Keep this as the only .php file at the root of the folder. The installer calls the hooks on the first root PHP file it finds.

4. Add an API route

Every PHP file under Routes/ returns a function that receives the route collection. registerAuthRoute only lets signed-in users through and puts the user in the request attributes:

<?php

use App\App;
use App\Helpers\ApiResponse;
use App\Plugins\PluginSettings;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\RouteCollection;

return function (RouteCollection $routes): void {
    App::getInstance(true)->registerAuthRoute(
        $routes,
        'helloworld-greeting',
        '/api/addons/helloworld/greeting',
        function (Request $request) {
            $user = $request->attributes->get('user');
            $greeting = PluginSettings::getSetting('helloworld', 'greeting') ?? 'Hello';

            return ApiResponse::success([
                'message' => $greeting . ', ' . $user['username'] . '!',
            ], 'Greeting loaded', 200);
        },
        ['GET']
    );
};

Prefix the route name with your identifier and keep the path under /api/addons/<identifier>/. Route names are global, so a clash with another route replaces it. ApiResponse::success() wraps your data as {"success": true, "message": ..., "data": {...}}. More helpers and permission checks are on Routes and permissions.

5. Add a dashboard page

Frontend/sidebar.json adds an entry to the user sidebar. The client key is the user dashboard, so this page opens at /dashboard/helloworld:

{
  "client": {
    "/helloworld": {
      "name": "Hello World",
      "lucideIcon": "hand",
      "redirect": "/helloworld",
      "component": "index.html",
      "description": "Say hello",
      "category": "client"
    }
  }
}

component is a path inside Frontend/Components/. The panel serves that folder at /components/helloworld/ and shows the file in an iframe on the panel’s own origin, so a fetch to /api/... carries the user’s session:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Hello World</title>
    <style>
      body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; }
      body.dark { background: #0b0b0c; color: #e5e5e5; }
    </style>
  </head>
  <body>
    <h1 id="message">Loading...</h1>
    <script>
      const params = new URLSearchParams(location.search);
      if (params.get('__theme') === 'dark') document.body.classList.add('dark');

      fetch('/api/addons/helloworld/greeting', { credentials: 'same-origin' })
        .then((res) => res.json())
        .then((json) => {
          document.getElementById('message').textContent = json.success
            ? json.data.message
            : json.message;
        });
    </script>
  </body>
</html>

The Frontend page lists every sidebar field and the query parameters the iframe receives.

6. Load the plugin

Pick one of these:

  • Package and install it. Zip the contents of the folder, with conf.yml at the root of the archive rather than inside a folder, name the file helloworld.fpa and upload it under Admin > Plugins. The installer links Frontend/Components/, runs migrations and calls pluginInstall(). See Packaging and publishing.
  • Copy it by hand. Copy the folder into storage/addons/, open Admin > Plugins > Hello World and click Resync. Resync links Frontend/Components/ to public/components/helloworld. pluginInstall() does not run this way, which is why the route falls back to 'Hello'.

Then open the plugin page in the admin area. It shows whether the plugin is loaded, any unmet dependencies and any missing required settings. Open /dashboard/helloworld to see the page.

7. Change the setting

Open Admin > Plugins > Hello World > Settings, type a new greeting and save. Reload /dashboard/helloworld. The route reads the setting on each call, so the new text shows at once. The Settings page explains field types and how values are stored.

Next steps

Frequently asked questions

Why does my new route return API_ROUTE_NOT_FOUND?

In the Docker image the API runs in FrankenPHP worker mode, so a worker only sees route files that existed when it started. Click Resync on the plugin page or restart the backend container, then try again.

Does pluginInstall run when I copy a plugin folder by hand?

No. pluginInstall runs when the plugin is created in Admin > Dev > Plugins or installed from an upload, a URL or the marketplace. Code that reads a setting should still handle a missing value.