Plugin Development
Plugin Development Guide
Plugins extend Minnow with new features — custom database tables, REST APIs, admin pages, settings, post types, and more. Everything is defined declaratively in a single plugin.yaml manifest.
Plugin Structure
data/plugins/my-plugin/
plugin.yaml # Required manifest
src/
MyController.php # Auto-loaded PHP classes
Lib/
Helper.php
assets/
css/
js/
templates/
Plugins live in data/plugins/. The directory name is the plugin ID.
Manifest Basics
Every plugin needs a plugin.yaml at its root. The name and version fields are required at the top level (not nested under a plugin: key).
name: My Plugin
namespace: MyPlugin
slug: my-plugin
version: 1.0.0
description: What this plugin does
author: Your Name
requires: 0.1.0
| Field | Required | Description |
|---|---|---|
name |
Yes | Display name |
version |
Yes | Semver version string |
namespace |
No | PHP namespace for autoloading classes in src/ |
slug |
No | Plugin slug (defaults to directory name) |
description |
No | Short description |
author |
No | Author name |
requires |
No | Minimum Minnow version |
Database Tables
Define tables under database.tables. Minnow auto-creates and migrates them when the plugin is activated.
database:
tables:
- name: minnow_bookmarks
columns:
- name: id
type: bigint
auto_increment: true
primary: true
- name: user_id
type: bigint
nullable: false
- name: url
type: varchar(500)
- name: title
type: varchar(255)
- name: description
type: text
nullable: true
- name: is_public
type: boolean
default: false
- name: created_at
type: datetime
- name: updated_at
type: datetime
nullable: true
indexes:
- columns: [user_id]
- columns: [url]
type: unique
Supported Column Types
| Type | MySQL Result |
|---|---|
varchar(N), char(N) |
STRING(N) |
text, longtext, mediumtext |
TEXT |
int, integer, bigint |
INTEGER / BIGINTEGER |
tinyint, boolean, bool |
BOOLEAN (TINYINT(1)) |
decimal(N,M) |
DECIMAL(N,M) |
float, double |
FLOAT |
datetime, timestamp |
DATETIME |
date |
DATE |
json |
JSON |
enum |
STRING(50) |
Entities
Entities map database tables to PHP objects. Define them under entities:.
entities:
Bookmark:
table: minnow_bookmarks
uses_prefix: true
primary_key: id
timestamps: true
fillable: [user_id, url, title, description, is_public]
hidden: [user_id]
casts:
is_public: bool
created_at: datetime
properties:
- name: id
type: bigint
primary: true
- name: user_id
type: bigint
- name: url
type: string
length: 500
- name: title
type: string
length: 255
- name: description
type: text
nullable: true
- name: is_public
type: boolean
default: false
- name: created_at
type: datetime
- name: updated_at
type: datetime
nullable: true
Key Entity Options
| Field | Description |
|---|---|
table |
Database table name (required) |
uses_prefix |
true if table uses the Minnow prefix (e.g. minnow_). Set false for tables created without prefix. |
primary_key |
Primary key column (default: id) |
timestamps |
Auto-manage created_at/updated_at |
fillable |
Mass-assignable columns |
hidden |
Columns excluded from JSON/array output |
casts |
Type casting map (bool, int, float, string, array, json, datetime) |
Property Options
| Field | Description |
|---|---|
name |
Column name (required) |
type |
Data type (required) |
length |
Column length for string types |
nullable |
Allow NULL values |
default |
Default value |
primary |
Is primary key |
hidden |
Exclude from generated Vue admin pages |
API Routes
Define REST endpoints under api:. Routes can be auto-generated from entities or defined manually.
api:
namespace: my-plugin/v1
routes:
# Auto-generated CRUD from entity
- entity: Bookmark
path: /bookmarks
capability: manage_options
operations: [list, get, create, update, delete]
# Custom route
- path: /bookmarks/{id}/share
method: POST
controller: BookmarkController::share
capability: edit_posts
Entity Routes
When you specify entity:, Minnow generates these endpoints automatically:
| Operation | Method | Path | Description |
|---|---|---|---|
list |
GET | /bookmarks |
List all |
get |
GET | /bookmarks/{id} |
Get one |
create |
POST | /bookmarks |
Create |
update |
PUT | /bookmarks/{id} |
Full update |
patch |
PATCH | /bookmarks/{id} |
Partial update |
delete |
DELETE | /bookmarks/{id} |
Delete |
Use operations: to limit which endpoints are generated. Omit it to generate all six.
Custom Controllers
Controllers are PHP classes in src/. They receive a Request and return a Response.
<?php
namespace MyPlugin;
use Minnow\Core\Api\Request;
use Minnow\Core\Api\Response;
use Minnow\Core\Database\Connection;
class BookmarkController
{
public static function share(Request $request): Response
{
$id = $request->param('id');
$db = Connection::getInstance();
$bookmark = $db->fetchOne(
"SELECT * FROM {$db->table('minnow_bookmarks')} WHERE id = ?",
[$id]
);
if (!$bookmark) {
return Response::notFound('Bookmark not found');
}
$db->raw(
"UPDATE {$db->table('minnow_bookmarks')} SET is_public = 1 WHERE id = ?",
[$id]
);
return Response::success(['shared' => true]);
}
}
Route Implementations
For simple routes, you can inline PHP directly in the YAML using implementation::
api:
routes:
- path: /bookmarks/count
method: GET
implementation: |
$db = \Minnow\Core\Database\Connection::getInstance();
$count = $db->fetchOne("SELECT COUNT(*) as total FROM {$db->table('minnow_bookmarks')}");
return \Minnow\Core\Api\Response::json(['count' => (int)$count['total']]);
Controllers Section
Add extra use imports and private helper methods via the controllers: section:
controllers:
BookmarkController:
uses:
- Minnow\Core\Entity\User
helpers:
findBookmark: |
$db = \Minnow\Core\Database\Connection::getInstance();
return $db->fetchOne("SELECT * FROM {$db->table('minnow_bookmarks')} WHERE id = ?", [$id]);
Request & Response API
Request Methods
| Method | Description |
|---|---|
$request->param($key) |
Route parameters (e.g. {id}) |
$request->input($key, $default) |
Body + query params |
$request->query($key, $default) |
Query string only |
$request->all() |
All body + query merged |
$request->only(['key1', 'key2']) |
Subset of input |
$request->has($key) |
Check if input exists |
$request->method() |
HTTP method |
$request->header($key) |
Request header |
$request->user() |
Authenticated user |
$request->bearerToken() |
Bearer token from header |
There is no $request->get() method.
Response Methods
Response::json($data, $status) // JSON response
Response::success($data, $message) // {success: true, message, data}
Response::created($data, $message) // 201 created
Response::error($message, $status) // {error: true, message}
Response::validationError($errors) // 422 with field errors
Response::notFound($message) // 404
Response::unauthorized($message) // 401
Response::forbidden($message) // 403
Response::noContent() // 204 empty
Admin Interface
Menu
Define the admin sidebar menu under admin.menu:
admin:
menu:
- title: Bookmarks
slug: bookmarks
icon: bookmark
items:
- title: All Bookmarks
slug: bookmarks
- title: Settings
slug: bookmarks-settings
- divider: true
- title: Import
slug: bookmarks-import
Use items: for submenu entries (not children:). The icon value is a Lucide icon name.
Pages
Define admin pages under admin.pages. Each page has a slug, type, and type-specific configuration.
Page Types
| Type | Description |
|---|---|
list |
Entity table with columns, filters, bulk actions |
editor |
Create/edit form with fields and sidebar |
detail |
Read-only detail view |
settings |
Settings form with tabs and sections |
composite |
Grid of blocks (dashboards, tools) |
form-builder |
Visual form editor |
placeholder |
Static message for unfinished pages |
List Page
admin:
pages:
- slug: bookmarks
title: Bookmarks
type: list
entity: Bookmark
columns:
- field: title
label: Title
sortable: true
- field: url
label: URL
- field: is_public
label: Public
type: badge
filters:
- field: is_public
label: Status
type: select
options: [all, public, private]
row_actions: [edit, delete]
bulk_actions: [delete]
add_new: true
Supported row actions: edit, view, duplicate, shortcode, export, delete.
Filter options must be simple strings: options: [all, active, inactive] (not objects).
Editor Page
- slug: bookmarks-edit
title: Edit Bookmark
type: editor
entity: Bookmark
layout: two-column
fields:
- name: title
type: text
label: Title
required: true
- name: url
type: url
label: URL
- name: description
type: textarea
label: Description
sidebar:
- section: Visibility
fields:
- field: is_public
type: toggle
label: Public
Note: The page type is editor (not edit). Sidebar fields use field: (not name:).
Settings Page
- slug: bookmarks-settings
title: Settings
type: settings
Settings pages auto-load fields from the settings: section of the manifest.
Composite Page
- slug: bookmarks-dashboard
title: Dashboard
type: composite
blocks:
- type: stat-card
width: quarter
title: Total Bookmarks
endpoint: /bookmarks/count
value_key: count
- type: table
width: three-quarters
title: Recent Bookmarks
endpoint: /bookmarks?per_page=5
Available block types: stat-card, chart, table, recent-list, filter, form, key-value, alert, links, html. See the Composite Blocks documentation for details.
Settings
Define plugin settings as a flat list under settings::
settings:
- key: default_public
label: Default to Public
type: bool
group: general
default: false
- key: max_bookmarks
label: Max Bookmarks per User
type: number
group: general
default: 100
- key: allowed_domains
label: Allowed Domains
type: textarea
group: advanced
default: ''
description: One domain per line. Leave empty to allow all.
Settings must be a flat list, not nested under an options: key. Each setting needs a key and type. The group field organizes settings into tabs on the settings page.
Accessing Settings in PHP
use Minnow\Core\Plugin\Settings\SettingsManager;
$value = SettingsManager::get('my-plugin', 'default_public');
SettingsManager::set('my-plugin', 'max_bookmarks', 200);
Post Types & Taxonomies
Plugins can register custom post types and taxonomies:
post_types:
bookmark-collection:
label: Collection
singular: Collection
plural: Collections
public: true
has_archive: true
supports: [title, editor, thumbnail]
menu_icon: folder
rewrite:
slug: collections
taxonomies:
bookmark-tag:
label: Bookmark Tag
singular: Bookmark Tag
plural: Bookmark Tags
object_types: [bookmark-collection]
hierarchical: false
public: true
See the Custom Post Types documentation for the full configuration reference.
Validation Rules
- Post type names: max 20 characters, lowercase, alphanumeric + hyphens/underscores
- Taxonomy names: max 32 characters, must start with a letter
- Reserved post type names:
post,page,attachment,revision,nav_menu_item - Reserved taxonomy names:
category,post_tag,nav_menu,link_category,post_format
Meta Fields
Register custom metadata for posts, users, terms, or comments:
meta_fields:
- key: bookmark_rating
object_type: post
object_subtype: bookmark-collection
type: integer
single: true
default: 0
- key: user_bookmark_count
object_type: user
type: integer
single: true
Or group by object type:
meta_fields:
post:
- key: bookmark_rating
type: integer
user:
- key: user_bookmark_count
type: integer
Valid object types: post, user, term, comment. Valid field types: string, integer, number, boolean, array, object.
Cron Tasks
Schedule recurring tasks:
cron:
- hook: my_plugin_cleanup
schedule: daily
callback: BookmarkController::cleanup
description: Remove expired bookmarks
Built-in schedules: hourly, twicedaily, daily. The callback must be a static method (Class::method).
Hooks
Register action and filter callbacks:
hooks:
actions:
- hook: minnow.plugins.loaded
callback: BookmarkController::onLoaded
priority: 10
filters:
- hook: minnow.theme.context
callback: BookmarkController::addContext
priority: 10
Custom Capabilities
Register capabilities that can be assigned to roles:
capabilities:
- manage_bookmarks
- edit_bookmarks
- delete_bookmarks
Activation & Loading
- Place your plugin directory in
data/plugins/ - The plugin appears in the admin Plugins page
- Activate it from the admin UI (or add the ID to the active plugins list in the database)
- On activation, Minnow runs database migrations, registers routes, settings, pages, post types, etc.
Boot Order
PluginManifest::parse()readsplugin.yamlPluginManifest::validate()checks required fields- SPL autoloader registered for
src/classes - Database tables created/migrated via
SchemaManager - Entities, routes, settings, pages, metaboxes, post types, taxonomies, meta fields, cron tasks, hooks, and behaviors all registered
Plugin Lifecycle Hooks
| Hook | When |
|---|---|
minnow.plugins.loaded |
After all plugins initialized |
minnow.plugin.activated |
Plugin activated |
minnow.plugin.deactivated |
Plugin deactivated |
Complete Example
name: Bookmarks
namespace: Bookmarks
slug: bookmarks
version: 1.0.0
description: Save and organize bookmarks
database:
tables:
- name: minnow_bookmarks
columns:
- { name: id, type: bigint, auto_increment: true, primary: true }
- { name: user_id, type: bigint }
- { name: url, type: varchar(500) }
- { name: title, type: varchar(255) }
- { name: is_public, type: boolean, default: false }
- { name: created_at, type: datetime }
entities:
Bookmark:
table: minnow_bookmarks
uses_prefix: true
properties:
- { name: id, type: bigint, primary: true }
- { name: user_id, type: bigint }
- { name: url, type: string, length: 500 }
- { name: title, type: string, length: 255 }
- { name: is_public, type: boolean, default: false }
- { name: created_at, type: datetime }
api:
namespace: bookmarks/v1
routes:
- entity: Bookmark
path: /bookmarks
capability: edit_posts
settings:
- key: max_per_user
type: number
group: general
default: 100
admin:
menu:
- title: Bookmarks
slug: bookmarks
icon: bookmark
pages:
- slug: bookmarks
title: Bookmarks
type: list
entity: Bookmark
columns:
- { field: title, label: Title, sortable: true }
- { field: url, label: URL }
- { field: is_public, label: Public, type: badge }
row_actions: [edit, delete]
add_new: true
- slug: bookmarks-edit
title: Edit Bookmark
type: editor
entity: Bookmark
fields:
- { name: title, type: text, required: true }
- { name: url, type: url, required: true }
sidebar:
- section: Visibility
fields:
- { field: is_public, type: toggle }