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.ymlmanifest 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/addonsinside thefeatherpanel_backendcontainer.
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.ymlat the root of the archive rather than inside a folder, name the filehelloworld.fpaand upload it under Admin > Plugins. The installer linksFrontend/Components/, runs migrations and callspluginInstall(). See Packaging and publishing. - Copy it by hand. Copy the folder into
storage/addons/, open Admin > Plugins > Hello World and click Resync. Resync linksFrontend/Components/topublic/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
- Listen to panel events: Lifecycle and events.
- Store data in your own tables: Migrations and storage.
- Run work on a schedule: Cron jobs and CLI commands.
- Something does not load: Debugging plugins.
Frequently asked questions
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.
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.