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
.sqlare 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_migrationsasaddon:<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 withADDON_MIGRATION_FAILED.
Tips:
- Prefix your tables with
featherpanel_<identifier>_so they cannot clash with core tables or other plugins. - Use
IF NOT EXISTSandINSERT IGNOREso 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:
| 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 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.
Related
Frequently asked questions
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.
The panel database is MariaDB or MySQL, so write migrations in MySQL syntax.