View .md

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.

On this page

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:

Migrations/
├── 2026-03-01-10.00-create-myplugin-logs.sql
└── 2026-04-12-09.30-add-level-index.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

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:

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

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.

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.