# Plugin migrations and storage

> Create tables for a FeatherPanel plugin with SQL migrations, query them from model classes, and store small values in plugin settings or the panel cache.

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

## Migrations

Put plain SQL files in `Migrations/`. Name them so they sort in the order they must run. The convention, also used by
the plugin templates, is a timestamp followed by a description:

```text
Migrations/
├── 2026-03-01-10.00-create-myplugin-logs.sql
└── 2026-04-12-09.30-add-level-index.sql
```

```sql
CREATE TABLE IF NOT EXISTS `featherpanel_myplugin_logs` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `message` TEXT NOT NULL,
  `level` VARCHAR(20) DEFAULT 'info',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci;
```

How they run:

- Only files ending in `.sql` are picked up. They are sorted by file name.
- The whole file is sent to the database in one call, so a file may hold several statements.
- Each file that succeeds is recorded in `featherpanel_migrations` as `addon:<identifier>:<file name>` and never runs
  again.
- They run when the plugin is installed or updated from the admin area, and whenever someone runs
  `php cli migrate`. The Docker backend runs that command each time the container starts. A failure during install stops the install with `ADDON_MIGRATION_FAILED`.

Tips:

- Prefix your tables with `featherpanel_<identifier>_` so they cannot clash with core tables or other plugins.
- Use `IF NOT EXISTS` and `INSERT IGNORE` so a file is safe to run on a database that already has part of the
  schema.
- The panel does not drop plugin tables on uninstall. Drop them in `pluginUninstall()` if that is what your users
  expect.

## Models

By convention, database access goes in classes under `Chat/`, the same folder name the panel uses for its own models.
Use the panel's PDO connection:

```php
<?php

namespace App\Addons\myplugin\Chat;

use App\App;

class Log
{
    private static string $table = 'featherpanel_myplugin_logs';

    public static function create(string $message, string $level = 'info'): bool
    {
        $pdo = App::getInstance(true)->getDatabase()->getPdo();
        $stmt = $pdo->prepare('INSERT INTO ' . self::$table . ' (message, level) VALUES (:message, :level)');

        return $stmt->execute(['message' => $message, 'level' => $level]);
    }

    public static function latest(int $limit = 50): array
    {
        $pdo = App::getInstance(true)->getDatabase()->getPdo();
        $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' ORDER BY created_at DESC LIMIT :limit');
        $stmt->bindValue('limit', $limit, \PDO::PARAM_INT);
        $stmt->execute();

        return $stmt->fetchAll(\PDO::FETCH_ASSOC);
    }
}
```

Always use prepared statements for values that come from a request.

## Plugin settings

For a handful of values you do not need a table. `App\Plugins\PluginSettings` stores key and value strings per
plugin:

| Method | Use |
| --- | --- |
| `getSetting($identifier, $key)` | Read one value, `null` if unset. |
| `getSettings($identifier)` | Read all values. |
| `setSetting($identifier, $key, $value)` | Write one value. |
| `deleteSettings($identifier, $key)` | Remove a value. |

Settings declared in the `config:` block of the [manifest](https://featherpanel.com/docs/plugins/manifest/) are edited by admins on the
plugin's Settings tab and stored the same way. They are kept when the plugin is updated.

## Cache

`App\Cache\Cache` is the panel cache, backed by Redis when it is configured and by files otherwise.

```php
use App\Cache\Cache;

$stats = Cache::get('myplugin_stats');
if ($stats === null) {
    $stats = computeStats();
    Cache::put('myplugin_stats', $stats, 5); // minutes
}
```

Prefix keys with your identifier.

## Related

- [Lifecycle and events](https://featherpanel.com/docs/plugins/lifecycle-and-events/)
- [Routes and permissions](https://featherpanel.com/docs/plugins/routes-and-permissions/)

## Frequently asked questions

### Do plugin migrations run more than once?

No. Each executed file is recorded in featherpanel_migrations as addon:<identifier>:<file name> and skipped afterwards. Change the schema with a new file, not by editing an old one.

### Can a migration use PostgreSQL?

The panel database is MariaDB or MySQL, so write migrations in MySQL syntax.
