Minnow

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

  1. Place your plugin directory in data/plugins/
  2. The plugin appears in the admin Plugins page
  3. Activate it from the admin UI (or add the ID to the active plugins list in the database)
  4. On activation, Minnow runs database migrations, registers routes, settings, pages, post types, etc.

Boot Order

  1. PluginManifest::parse() reads plugin.yaml
  2. PluginManifest::validate() checks required fields
  3. SPL autoloader registered for src/ classes
  4. Database tables created/migrated via SchemaManager
  5. 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 }