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

Source: https://featherpanel.com/docs/plugins/quick-start/

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](https://featherpanel.com/docs/plugins/getting-started/).

## Before you start

- A running panel you can change files on. See [Installation](https://featherpanel.com/docs/panel/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.

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

## 2. Write conf.yml

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

```json
{
  "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:

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

> [!TIP]
> In the Docker image the API runs in FrankenPHP worker mode. Each worker loads plugins and route files once, when it
> starts. A successful change under **Admin > Plugins** (saving a setting, Resync) tells every worker to restart, so
> click Resync after you edit PHP by hand, or restart the backend container.

## 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](https://featherpanel.com/docs/plugins/settings/) page
explains field types and how values are stored.

## Next steps

- Listen to panel events: [Lifecycle and events](https://featherpanel.com/docs/plugins/lifecycle-and-events/).
- Store data in your own tables: [Migrations and storage](https://featherpanel.com/docs/plugins/database/).
- Run work on a schedule: [Cron jobs and CLI commands](https://featherpanel.com/docs/plugins/cron-and-commands/).
- Something does not load: [Debugging plugins](https://featherpanel.com/docs/plugins/debugging/).

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